@p10i/create-nestbox 0.4.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/.env.example +0 -4
- package/CHANGELOG.md +16 -0
- package/INSTALL.md +32 -24
- package/README.md +22 -4
- package/bin/create-nestbox.js +4 -85
- package/docker/control/Dockerfile +2 -2
- package/docker/control/server.py +4 -383
- package/docker/nginx/Dockerfile +11 -11
- package/docker/opencode/Dockerfile +6 -2
- package/docker/php-fpm/Dockerfile +22 -22
- package/docker-compose.yaml +1 -29
- package/home/code/__includes/footer.php +2 -2
- package/home/code/__includes/header.php +5 -5
- package/home/configs/nginx/nginx.conf +17 -34
- package/home/configs/opencode/control-mcp.js +1 -21
- package/home/configs/opencode/instructions.md +11 -12
- 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 -7
- package/tests/control.mjs +56 -23
- package/tests/creator.mjs +14 -19
- package/tests/nestbox.ps1 +6 -11
- package/tests/nestbox.sh +8 -9
- package/host-runner.mjs +0 -292
- package/package.host.json +0 -8
- package/tests/host-runner.mjs +0 -143
package/docker/control/server.py
CHANGED
|
@@ -1,34 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
import http.client
|
|
3
|
-
import hmac
|
|
4
3
|
import json
|
|
5
4
|
import os
|
|
6
5
|
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_TOKEN_FILE = os.environ.get("NESTBOX_HOST_TOKEN_FILE", "/run/secrets/nestbox-host-token")
|
|
21
|
-
try:
|
|
22
|
-
with open(HOST_TOKEN_FILE, encoding="utf-8") as token_stream:
|
|
23
|
-
HOST_TOKEN = token_stream.read().strip()
|
|
24
|
-
except OSError:
|
|
25
|
-
HOST_TOKEN = ""
|
|
26
|
-
HOST_TIMEOUT = int(os.environ.get("NESTBOX_HOST_RUNNER_TIMEOUT", "3600"))
|
|
27
|
-
HOST_HEARTBEAT_TTL = int(os.environ.get("NESTBOX_HOST_RUNNER_HEARTBEAT_TTL", "10"))
|
|
28
|
-
HOST_LONG_POLL_TIMEOUT = int(os.environ.get("NESTBOX_HOST_LONG_POLL_TIMEOUT", "25"))
|
|
29
|
-
MAX_HOST_JOBS = int(os.environ.get("NESTBOX_MAX_HOST_JOBS", "100"))
|
|
30
17
|
MAX_BODY_BYTES = 128 * 1024
|
|
31
|
-
MAX_HOST_BODY_BYTES = 64 * 1024 * 1024
|
|
32
18
|
MAX_COMMAND_PARTS = 256
|
|
33
19
|
MAX_COMMAND_BYTES = 64 * 1024
|
|
34
20
|
LOG_OUTPUT = os.environ.get("NESTBOX_CONTROL_LOG_OUTPUT", "text").lower()
|
|
@@ -183,188 +169,6 @@ def policy_error(source, target, user, configured_user=""):
|
|
|
183
169
|
return ""
|
|
184
170
|
|
|
185
171
|
|
|
186
|
-
def allows_npm(source):
|
|
187
|
-
labels = (source or {}).get("Labels") or {}
|
|
188
|
-
return bool(source) and truthy(labels, "nestbox.npm.allow") and not truthy(labels, "nestbox.exec.deny-source")
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
def package_scripts():
|
|
192
|
-
path = os.path.join(WORKSPACE_PATH, "package.json")
|
|
193
|
-
if not os.path.isfile(path):
|
|
194
|
-
return None
|
|
195
|
-
with open(path, encoding="utf-8") as stream:
|
|
196
|
-
document = json.load(stream)
|
|
197
|
-
scripts = document.get("scripts", {})
|
|
198
|
-
if not isinstance(scripts, dict):
|
|
199
|
-
raise ValueError("package.json scripts must be an object")
|
|
200
|
-
return {str(name): str(command) for name, command in scripts.items()}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
class HostBroker:
|
|
204
|
-
def __init__(self):
|
|
205
|
-
self.condition = threading.Condition()
|
|
206
|
-
self.pending = []
|
|
207
|
-
self.jobs = {}
|
|
208
|
-
self.runner = {}
|
|
209
|
-
|
|
210
|
-
def touch(self, runner_id, headers):
|
|
211
|
-
if not re.fullmatch(r"[A-Za-z0-9._:-]{8,128}", runner_id):
|
|
212
|
-
raise ValueError("invalid runner 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
|
-
"seen": now,
|
|
232
|
-
"pid": headers.get("X-Nestbox-Runner-Pid"),
|
|
233
|
-
"platform": headers.get("X-Nestbox-Runner-Platform"),
|
|
234
|
-
"hostname": headers.get("X-Nestbox-Runner-Host"),
|
|
235
|
-
}
|
|
236
|
-
self.condition.notify_all()
|
|
237
|
-
|
|
238
|
-
def status(self):
|
|
239
|
-
with self.condition:
|
|
240
|
-
if not self.runner:
|
|
241
|
-
return {"available": False}
|
|
242
|
-
age = max(0, time.monotonic() - self.runner["seen"])
|
|
243
|
-
return {
|
|
244
|
-
"available": age <= HOST_HEARTBEAT_TTL,
|
|
245
|
-
"ageSeconds": round(age, 3),
|
|
246
|
-
"pid": self.runner.get("pid"),
|
|
247
|
-
"platform": self.runner.get("platform"),
|
|
248
|
-
"hostname": self.runner.get("hostname"),
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
def enqueue(self, job):
|
|
252
|
-
with self.condition:
|
|
253
|
-
if len(self.jobs) >= MAX_HOST_JOBS:
|
|
254
|
-
raise RuntimeError("too many host jobs are pending")
|
|
255
|
-
self.jobs[job["jobId"]] = {"job": job, "result": None, "runnerId": None}
|
|
256
|
-
self.pending.append(job["jobId"])
|
|
257
|
-
self.condition.notify_all()
|
|
258
|
-
|
|
259
|
-
def next(self, runner_id, headers, timeout):
|
|
260
|
-
deadline = time.monotonic() + timeout
|
|
261
|
-
with self.condition:
|
|
262
|
-
while True:
|
|
263
|
-
if self.runner.get("id") != runner_id:
|
|
264
|
-
raise PermissionError("another host runner is active")
|
|
265
|
-
if time.monotonic() - self.runner.get("seen", 0) > HOST_HEARTBEAT_TTL:
|
|
266
|
-
return None
|
|
267
|
-
if self.pending:
|
|
268
|
-
identifier = self.pending.pop(0)
|
|
269
|
-
record = self.jobs.get(identifier)
|
|
270
|
-
if record:
|
|
271
|
-
record["runnerId"] = runner_id
|
|
272
|
-
return record["job"]
|
|
273
|
-
remaining = deadline - time.monotonic()
|
|
274
|
-
if remaining <= 0:
|
|
275
|
-
return None
|
|
276
|
-
self.condition.wait(min(1, remaining))
|
|
277
|
-
|
|
278
|
-
def complete(self, runner_id, identifier, result):
|
|
279
|
-
with self.condition:
|
|
280
|
-
record = self.jobs.get(identifier)
|
|
281
|
-
if not record or record.get("runnerId") != runner_id:
|
|
282
|
-
raise ValueError("host job is unknown or belongs to another runner")
|
|
283
|
-
if record.get("result") is not None:
|
|
284
|
-
raise ValueError("host job already has a result")
|
|
285
|
-
if result.get("jobId") != identifier:
|
|
286
|
-
raise ValueError("host result contains a mismatched job ID")
|
|
287
|
-
job = record["job"]
|
|
288
|
-
if job["type"] == "npm.run":
|
|
289
|
-
if not isinstance(result.get("exitCode"), int) or not isinstance(result.get("stdout"), str) or not isinstance(result.get("stderr"), str):
|
|
290
|
-
raise ValueError("invalid npm run result")
|
|
291
|
-
elif job["type"] == "npm.scripts.request":
|
|
292
|
-
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"):
|
|
293
|
-
if not isinstance(result.get("exitCode"), int):
|
|
294
|
-
raise ValueError("invalid script change request result")
|
|
295
|
-
elif job["type"] == "npm.scripts.confirm":
|
|
296
|
-
if result.get("status") == "success" and result.get("requestId") != job.get("requestId"):
|
|
297
|
-
raise ValueError("invalid script confirmation result")
|
|
298
|
-
if result.get("status") != "success" and not isinstance(result.get("exitCode"), int):
|
|
299
|
-
raise ValueError("invalid script confirmation result")
|
|
300
|
-
record["result"] = result
|
|
301
|
-
self.condition.notify_all()
|
|
302
|
-
|
|
303
|
-
def wait(self, identifier, timeout=HOST_TIMEOUT):
|
|
304
|
-
deadline = time.monotonic() + timeout
|
|
305
|
-
with self.condition:
|
|
306
|
-
while True:
|
|
307
|
-
record = self.jobs.get(identifier)
|
|
308
|
-
if record and record.get("runnerId") and record.get("result") is None:
|
|
309
|
-
owner = record["runnerId"]
|
|
310
|
-
if self.runner.get("id") != owner or time.monotonic() - self.runner.get("seen", 0) > HOST_HEARTBEAT_TTL:
|
|
311
|
-
record["result"] = {
|
|
312
|
-
"jobId": identifier,
|
|
313
|
-
"exitCode": 1,
|
|
314
|
-
"stdout": "",
|
|
315
|
-
"stderr": "The assigned host runner disconnected before returning a result.",
|
|
316
|
-
"interrupted": True,
|
|
317
|
-
}
|
|
318
|
-
elif record and record.get("result") is None and self.runner and time.monotonic() - self.runner.get("seen", 0) > HOST_HEARTBEAT_TTL:
|
|
319
|
-
record["result"] = {
|
|
320
|
-
"jobId": identifier,
|
|
321
|
-
"exitCode": 1,
|
|
322
|
-
"stdout": "",
|
|
323
|
-
"stderr": "The host runner disconnected before accepting the job.",
|
|
324
|
-
"interrupted": True,
|
|
325
|
-
}
|
|
326
|
-
if record and record.get("result") is not None:
|
|
327
|
-
result = record["result"]
|
|
328
|
-
del self.jobs[identifier]
|
|
329
|
-
return result
|
|
330
|
-
remaining = deadline - time.monotonic()
|
|
331
|
-
if remaining <= 0:
|
|
332
|
-
self.jobs.pop(identifier, None)
|
|
333
|
-
if identifier in self.pending:
|
|
334
|
-
self.pending.remove(identifier)
|
|
335
|
-
raise TimeoutError(f"host runner did not finish within {timeout} seconds")
|
|
336
|
-
self.condition.wait(min(1, remaining))
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
HOST_BROKER = HostBroker()
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
def host_status():
|
|
343
|
-
return HOST_BROKER.status()
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
def enqueue(job):
|
|
347
|
-
HOST_BROKER.enqueue(job)
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
def wait_result(identifier, timeout=HOST_TIMEOUT):
|
|
351
|
-
return HOST_BROKER.wait(identifier, timeout)
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
def record_host_result(identifier, source_name, script, result, detached):
|
|
355
|
-
stdout, stderr = str(result.get("stdout", "")), str(result.get("stderr", ""))
|
|
356
|
-
log_output(identifier, "npm.stdout", stdout)
|
|
357
|
-
log_output(identifier, "npm.stderr", stderr)
|
|
358
|
-
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")))
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
def wait_detached(identifier, source_name, script):
|
|
362
|
-
try:
|
|
363
|
-
record_host_result(identifier, source_name, script, wait_result(identifier), True)
|
|
364
|
-
except Exception as error:
|
|
365
|
-
log_event("npm.error", runId=identifier, source=source_name, script=script, detached=True, error=str(error))
|
|
366
|
-
|
|
367
|
-
|
|
368
172
|
def valid_command(command):
|
|
369
173
|
return (isinstance(command, list) and 0 < len(command) <= MAX_COMMAND_PARTS
|
|
370
174
|
and all(isinstance(part, str) and "\0" not in part for part in command)
|
|
@@ -372,7 +176,7 @@ def valid_command(command):
|
|
|
372
176
|
|
|
373
177
|
|
|
374
178
|
class Handler(BaseHTTPRequestHandler):
|
|
375
|
-
server_version = "nestbox-control/0.
|
|
179
|
+
server_version = "nestbox-control/0.6"
|
|
376
180
|
|
|
377
181
|
def setup(self):
|
|
378
182
|
super().setup()
|
|
@@ -408,21 +212,9 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
408
212
|
if path == "/health":
|
|
409
213
|
try:
|
|
410
214
|
docker_request("GET", "/_ping")
|
|
411
|
-
self.send_json(200, {"ok": True, "dockerSocketAccess": True
|
|
412
|
-
except Exception as error:
|
|
413
|
-
self.send_json(503, {"ok": False, "dockerSocketAccess": False, "error": str(error), "hostRunner": host_status()})
|
|
414
|
-
return
|
|
415
|
-
if path == "/npm/scripts":
|
|
416
|
-
source = self.caller()
|
|
417
|
-
if not allows_npm(source):
|
|
418
|
-
self.send_json(403, {"error": "caller is not allowed to use npm"})
|
|
419
|
-
return
|
|
420
|
-
try:
|
|
421
|
-
scripts = package_scripts()
|
|
215
|
+
self.send_json(200, {"ok": True, "dockerSocketAccess": True})
|
|
422
216
|
except Exception as error:
|
|
423
|
-
self.send_json(
|
|
424
|
-
return
|
|
425
|
-
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)})
|
|
426
218
|
return
|
|
427
219
|
self.send_json(404, {"error": "not found"})
|
|
428
220
|
|
|
@@ -435,12 +227,6 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
435
227
|
path = urlparse(self.path).path
|
|
436
228
|
if path.startswith("/containers/") and path.endswith("/exec"):
|
|
437
229
|
self.container_exec(unquote(path[len("/containers/"):-len("/exec")]).strip("/"), body)
|
|
438
|
-
elif path == "/npm/run":
|
|
439
|
-
self.npm_run(body)
|
|
440
|
-
elif path == "/npm/scripts/request":
|
|
441
|
-
self.script_request(body)
|
|
442
|
-
elif path == "/npm/scripts/confirm":
|
|
443
|
-
self.script_confirm(body)
|
|
444
230
|
else:
|
|
445
231
|
self.send_json(404, {"error": "not found"})
|
|
446
232
|
|
|
@@ -484,175 +270,10 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
484
270
|
log_event("exec.error", runId=identifier, source=service(source), target=service(target), command=command, error=str(error))
|
|
485
271
|
self.send_json(502, {"error": str(error)})
|
|
486
272
|
|
|
487
|
-
def npm_context(self):
|
|
488
|
-
source = self.caller()
|
|
489
|
-
if not allows_npm(source):
|
|
490
|
-
self.send_json(403, {"error": "caller is not allowed to use npm"})
|
|
491
|
-
return None
|
|
492
|
-
if not host_status().get("available"):
|
|
493
|
-
self.send_json(503, {"error": "host runner is unavailable; run `npm run host --` from the workspace"})
|
|
494
|
-
return None
|
|
495
|
-
return source
|
|
496
|
-
|
|
497
|
-
def npm_run(self, body):
|
|
498
|
-
source = self.npm_context()
|
|
499
|
-
if source is None: return
|
|
500
|
-
script, args = body.get("script"), body.get("args", [])
|
|
501
|
-
if not isinstance(script, str) or not re.fullmatch(r"[A-Za-z0-9:_@./ -]{1,128}", script) or script == "host":
|
|
502
|
-
self.send_json(400, {"error": "invalid or protected script name"}); return
|
|
503
|
-
if not isinstance(args, list) or not all(isinstance(arg, str) and "\0" not in arg for arg in args):
|
|
504
|
-
self.send_json(400, {"error": "args must be a string array"}); return
|
|
505
|
-
try:
|
|
506
|
-
scripts = package_scripts()
|
|
507
|
-
except Exception as error:
|
|
508
|
-
self.send_json(400, {"error": str(error)}); return
|
|
509
|
-
if scripts is None or script not in scripts:
|
|
510
|
-
self.send_json(404, {"error": "script is not defined in /workspace/package.json"}); return
|
|
511
|
-
identifier = run_id("npm")
|
|
512
|
-
job = {"schemaVersion": 1, "jobId": identifier, "type": "npm.run", "script": script, "args": args, "source": service(source), "createdAt": now_iso()}
|
|
513
|
-
try:
|
|
514
|
-
enqueue(job)
|
|
515
|
-
if body.get("detach"):
|
|
516
|
-
threading.Thread(target=wait_detached, args=(identifier, service(source), script), daemon=True).start()
|
|
517
|
-
self.send_json(202, {"runId": identifier, "detached": True, "executor": "host"}); return
|
|
518
|
-
result = wait_result(identifier)
|
|
519
|
-
record_host_result(identifier, service(source), script, result, False)
|
|
520
|
-
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"})
|
|
521
|
-
except Exception as error:
|
|
522
|
-
self.send_json(502, {"error": str(error)})
|
|
523
|
-
|
|
524
|
-
def script_request(self, body):
|
|
525
|
-
source = self.npm_context()
|
|
526
|
-
if source is None: return
|
|
527
|
-
operation, name, command = body.get("operation"), body.get("name"), body.get("command")
|
|
528
|
-
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":
|
|
529
|
-
self.send_json(400, {"error": "invalid script change"}); return
|
|
530
|
-
if operation != "delete" and (not isinstance(command, str) or not command.strip() or len(command) > 8192):
|
|
531
|
-
self.send_json(400, {"error": "command must be a non-empty string of at most 8192 characters"}); return
|
|
532
|
-
if operation == "delete" and command is not None:
|
|
533
|
-
self.send_json(400, {"error": "delete does not accept a command"}); return
|
|
534
|
-
identifier = run_id("npm-script-request")
|
|
535
|
-
job = {"schemaVersion": 1, "jobId": identifier, "type": "npm.scripts.request", "requestId": identifier, "operation": operation, "name": name, "source": service(source), "createdAt": now_iso()}
|
|
536
|
-
if operation != "delete": job["command"] = command
|
|
537
|
-
try:
|
|
538
|
-
enqueue(job)
|
|
539
|
-
result = wait_result(identifier)
|
|
540
|
-
if result.get("status") != "confirmation_required": raise ValueError(result.get("stderr", "host runner rejected the request"))
|
|
541
|
-
self.send_json(202, {"status": "confirmation_required", "requestId": result.get("requestId"), "operation": operation, "name": name, "expiresAt": result.get("expiresAt")})
|
|
542
|
-
except Exception as error:
|
|
543
|
-
self.send_json(400, {"error": str(error)})
|
|
544
|
-
|
|
545
|
-
def script_confirm(self, body):
|
|
546
|
-
source = self.npm_context()
|
|
547
|
-
if source is None: return
|
|
548
|
-
request_id, code = body.get("requestId"), body.get("code")
|
|
549
|
-
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):
|
|
550
|
-
self.send_json(400, {"error": "invalid request ID or confirmation code"}); return
|
|
551
|
-
identifier = run_id("npm-script-confirm")
|
|
552
|
-
job = {"schemaVersion": 1, "jobId": identifier, "type": "npm.scripts.confirm", "requestId": request_id, "code": code, "source": service(source), "createdAt": now_iso()}
|
|
553
|
-
try:
|
|
554
|
-
enqueue(job)
|
|
555
|
-
result = wait_result(identifier)
|
|
556
|
-
if result.get("status") != "success": raise ValueError(result.get("stderr", "host runner rejected the confirmation"))
|
|
557
|
-
self.send_json(200, {"status": "success", "requestId": request_id, "operation": result.get("operation"), "name": result.get("name")})
|
|
558
|
-
except Exception as error:
|
|
559
|
-
self.send_json(400, {"error": str(error)})
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
class HostHandler(BaseHTTPRequestHandler):
|
|
563
|
-
server_version = "nestbox-host-control/0.4"
|
|
564
|
-
|
|
565
|
-
def setup(self):
|
|
566
|
-
super().setup()
|
|
567
|
-
self.connection.settimeout(HOST_LONG_POLL_TIMEOUT + 10)
|
|
568
|
-
|
|
569
|
-
def log_message(self, fmt, *args):
|
|
570
|
-
log_event("host.http.access", client=self.address_string(), message=fmt % args)
|
|
571
|
-
|
|
572
|
-
def send_json(self, status, value):
|
|
573
|
-
payload = json.dumps(value, ensure_ascii=False).encode()
|
|
574
|
-
self.send_response(status)
|
|
575
|
-
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
576
|
-
self.send_header("Content-Length", str(len(payload)))
|
|
577
|
-
self.end_headers()
|
|
578
|
-
self.wfile.write(payload)
|
|
579
|
-
|
|
580
|
-
def authorized(self):
|
|
581
|
-
supplied = self.headers.get("Authorization", "")
|
|
582
|
-
expected = f"Bearer {HOST_TOKEN}"
|
|
583
|
-
return hmac.compare_digest(supplied.encode(), expected.encode())
|
|
584
|
-
|
|
585
|
-
def runner(self):
|
|
586
|
-
return self.headers.get("X-Nestbox-Runner-Id", "")
|
|
587
|
-
|
|
588
|
-
def body(self):
|
|
589
|
-
if self.headers.get("Transfer-Encoding"):
|
|
590
|
-
raise ValueError("transfer encoding is not supported")
|
|
591
|
-
length = int(self.headers.get("Content-Length", "0"))
|
|
592
|
-
if length < 0 or length > MAX_HOST_BODY_BYTES:
|
|
593
|
-
raise ValueError("request body is too large")
|
|
594
|
-
value = json.loads(self.rfile.read(length)) if length else {}
|
|
595
|
-
if not isinstance(value, dict):
|
|
596
|
-
raise ValueError("request body must be a JSON object")
|
|
597
|
-
return value
|
|
598
|
-
|
|
599
|
-
def authenticate(self):
|
|
600
|
-
if not self.authorized():
|
|
601
|
-
self.send_json(401, {"error": "invalid host runner token"})
|
|
602
|
-
return False
|
|
603
|
-
try:
|
|
604
|
-
HOST_BROKER.touch(self.runner(), self.headers)
|
|
605
|
-
return True
|
|
606
|
-
except PermissionError as error:
|
|
607
|
-
self.send_json(409, {"error": str(error)})
|
|
608
|
-
except ValueError as error:
|
|
609
|
-
self.send_json(400, {"error": str(error)})
|
|
610
|
-
return False
|
|
611
|
-
|
|
612
|
-
def do_GET(self):
|
|
613
|
-
parsed = urlparse(self.path)
|
|
614
|
-
if parsed.path == "/host/health":
|
|
615
|
-
if not self.authenticate(): return
|
|
616
|
-
self.send_json(200, {"ok": True, "hostRunner": host_status()})
|
|
617
|
-
return
|
|
618
|
-
if parsed.path == "/host/jobs/next":
|
|
619
|
-
if not self.authenticate(): return
|
|
620
|
-
try:
|
|
621
|
-
requested = int((parse_qs(parsed.query).get("timeout") or [HOST_LONG_POLL_TIMEOUT])[0])
|
|
622
|
-
timeout = max(1, min(HOST_LONG_POLL_TIMEOUT, requested))
|
|
623
|
-
job = HOST_BROKER.next(self.runner(), self.headers, timeout)
|
|
624
|
-
if job is None:
|
|
625
|
-
self.send_response(204); self.end_headers(); return
|
|
626
|
-
self.send_json(200, job)
|
|
627
|
-
except (TypeError, ValueError) as error:
|
|
628
|
-
self.send_json(400, {"error": str(error)})
|
|
629
|
-
return
|
|
630
|
-
self.send_json(404, {"error": "not found"})
|
|
631
|
-
|
|
632
|
-
def do_POST(self):
|
|
633
|
-
if not self.authenticate(): return
|
|
634
|
-
path = urlparse(self.path).path
|
|
635
|
-
if path == "/host/heartbeat":
|
|
636
|
-
self.send_json(200, {"ok": True})
|
|
637
|
-
return
|
|
638
|
-
match = re.fullmatch(r"/host/jobs/([A-Za-z0-9._:-]{1,128})/result", path)
|
|
639
|
-
if not match:
|
|
640
|
-
self.send_json(404, {"error": "not found"}); return
|
|
641
|
-
try:
|
|
642
|
-
result = self.body()
|
|
643
|
-
HOST_BROKER.complete(self.runner(), match.group(1), result)
|
|
644
|
-
self.send_json(202, {"accepted": True})
|
|
645
|
-
except ValueError as error:
|
|
646
|
-
self.send_json(400, {"error": str(error)})
|
|
647
|
-
|
|
648
273
|
|
|
649
274
|
def main():
|
|
650
275
|
if not re.fullmatch(r"[a-z0-9][a-z0-9_-]*", PROJECT_NAME):
|
|
651
276
|
raise SystemExit("COMPOSE_PROJECT_NAME must be a non-empty lowercase project slug")
|
|
652
|
-
if len(HOST_TOKEN) < 32:
|
|
653
|
-
raise SystemExit("NESTBOX_HOST_TOKEN_FILE must contain a token of at least 32 characters")
|
|
654
|
-
host_server = ThreadingHTTPServer(("0.0.0.0", 4089), HostHandler)
|
|
655
|
-
threading.Thread(target=host_server.serve_forever, daemon=True).start()
|
|
656
277
|
ThreadingHTTPServer(("0.0.0.0", int(os.environ.get("NESTBOX_CONTROL_PORT", "4088"))), Handler).serve_forever()
|
|
657
278
|
|
|
658
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:
|
|
@@ -114,26 +108,11 @@ services:
|
|
|
114
108
|
dockerfile: ./docker/control/Dockerfile
|
|
115
109
|
expose:
|
|
116
110
|
- "4088"
|
|
117
|
-
# Authenticated host-runner transport, proxied only through Nginx.
|
|
118
|
-
- "4089"
|
|
119
111
|
volumes:
|
|
120
112
|
- ${DOCKER_SOCKET_PATH:-/var/run/docker.sock}:/var/run/docker.sock
|
|
121
|
-
- type: bind
|
|
122
|
-
source: ${WORKSPACE_PATH:-.}
|
|
123
|
-
target: /workspace
|
|
124
|
-
read_only: true
|
|
125
|
-
bind:
|
|
126
|
-
create_host_path: false
|
|
127
|
-
- type: bind
|
|
128
|
-
source: ${NESTBOX_HOST_TOKEN_FILE:?NESTBOX_HOST_TOKEN_FILE must be configured}
|
|
129
|
-
target: /run/secrets/nestbox-host-token
|
|
130
|
-
read_only: true
|
|
131
|
-
bind:
|
|
132
|
-
create_host_path: false
|
|
133
113
|
environment:
|
|
134
114
|
- COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME}
|
|
135
115
|
- NESTBOX_CONTROL_SERVICE=control
|
|
136
|
-
- NESTBOX_HOST_TOKEN_FILE=/run/secrets/nestbox-host-token
|
|
137
116
|
- NESTBOX_CONTROL_LOG_OUTPUT=${NESTBOX_CONTROL_LOG_OUTPUT:-text}
|
|
138
117
|
- NESTBOX_CONTROL_LOG_MAX_BYTES=${NESTBOX_CONTROL_LOG_MAX_BYTES:-262144}
|
|
139
118
|
healthcheck:
|
|
@@ -182,12 +161,6 @@ services:
|
|
|
182
161
|
target: /workspace
|
|
183
162
|
bind:
|
|
184
163
|
create_host_path: false
|
|
185
|
-
- type: bind
|
|
186
|
-
source: ${WORKSPACE_PATH:-.}/package.json
|
|
187
|
-
target: /workspace/package.json
|
|
188
|
-
read_only: true
|
|
189
|
-
bind:
|
|
190
|
-
create_host_path: false
|
|
191
164
|
# The entrypoint fixes this volume's ownership before dropping privileges.
|
|
192
165
|
- opencode-data:/home/nestbox/.local/share/opencode
|
|
193
166
|
working_dir: /workspace
|
|
@@ -197,7 +170,6 @@ services:
|
|
|
197
170
|
labels:
|
|
198
171
|
- nestbox.exec.allow-source=true
|
|
199
172
|
- nestbox.exec.targets=php-fpm,rollup
|
|
200
|
-
- nestbox.npm.allow=true
|
|
201
173
|
restart: unless-stopped
|
|
202
174
|
|
|
203
175
|
volumes:
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
</article>
|
|
2
|
-
</body>
|
|
1
|
+
</article>
|
|
2
|
+
</body>
|
|
3
3
|
</html>
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
2
|
<html lang="en">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="utf-8">
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
-
<title><?= htmlspecialchars($title ?? 'Nestbox', ENT_QUOTES, 'UTF-8'); ?></title>
|
|
7
|
-
<!-- Pre-built bundle of every shared dependency (Bootstrap, GitHub Markdown CSS, …): -->
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title><?= htmlspecialchars($title ?? 'Nestbox', ENT_QUOTES, 'UTF-8'); ?></title>
|
|
7
|
+
<!-- Pre-built bundle of every shared dependency (Bootstrap, GitHub Markdown CSS, …): -->
|
|
8
8
|
<link rel="stylesheet" href="<?= nestbox_escape(nestbox_url_path('scripts-bundle.css')); ?>">
|
|
9
9
|
<script defer src="<?= nestbox_escape(nestbox_url_path('scripts-bundle.js')); ?>"></script>
|
|
10
10
|
<!-- Site-wide overrides: -->
|