@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.
@@ -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 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_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.5"
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, "workspacePackageJson": os.path.isfile(os.path.join(WORKSPACE_PATH, "package.json")), "hostRunner": host_status()})
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), "hostRunner": host_status()})
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
 
@@ -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:
@@ -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: