@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.
@@ -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 parse_qs, quote, unquote, urlparse
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.4"
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, "workspacePackageJson": os.path.isfile(os.path.join(WORKSPACE_PATH, "package.json")), "hostRunner": host_status()})
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(400, {"error": str(error)})
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
 
@@ -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:3.23
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
- RUN apk add --no-cache git nodejs php85 php85-dom php85-mbstring php85-openssl php85-phar php85-tokenizer php85-xml php85-xmlwriter su-exec && \
14
- ln -s /usr/bin/php85 /usr/local/bin/php && \
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
@@ -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 bridge for container exec and native host npm jobs.
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: -->