From 3c8ff9261236da984b4f15cea4bdb30730ee4f79 Mon Sep 17 00:00:00 2001 From: ttbombadil Date: Mon, 12 Oct 2026 16:16:14 +0200 Subject: [PATCH 1/3] fix(deploy): pass capacity.env values through Compose and prepare runtime dirs Two defects found on a fresh Ubuntu 26.04 host: - docker-compose.yml did not forward SIMULATION_QUEUE_TIMEOUT_MS and pinned SIMULATION_MAX_CONCURRENT to 200, so two of the seven capacity.env recommendations never reached the backend. Both are now read from .env. Defaults are unchanged: 200 for the simulation limit, and an empty queue timeout keeps the application default (60000 ms) from server/config.ts instead of introducing a second default. - The bind-mounted server/arduino-cache, temp and storage were root:root (created by Docker) or owned by the installing host user, so the backend user (node, UID/GID 1000) got EACCES on every compile. The new scripts/prepare-runtime-dirs.sh creates them and hands them to UID/GID 1000 with mode 0750; the installer calls it. It targets the container user, never the host UID, and the backend still does not run as root. The installer deployment test now prepares the directories with the real script instead of chmod 0777, asserts the backend can write them, and checks that .env capacity values reach /api/status. Co-Authored-By: Claude Sonnet 5.5 --- docker-compose.yml | 9 +- docs/CAPACITY_MODEL.md | 7 +- docs/INSTALL_SERVER.md | 27 +++- docs/INSTALL_UBUNTU_VBOX.md | 5 + scripts/deployment/installer-deployment.ts | 78 ++++++++-- scripts/install-ubuntu-vbox.sh | 9 +- scripts/prepare-runtime-dirs.sh | 98 +++++++++++++ .../compose-capacity-passthrough.test.ts | 98 +++++++++++++ tests/server/prepare-runtime-dirs.test.ts | 137 ++++++++++++++++++ 9 files changed, 449 insertions(+), 19 deletions(-) create mode 100755 scripts/prepare-runtime-dirs.sh create mode 100644 tests/server/compose-capacity-passthrough.test.ts create mode 100644 tests/server/prepare-runtime-dirs.test.ts diff --git a/docker-compose.yml b/docker-compose.yml index de617bebc..19d41034e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -58,7 +58,10 @@ services: # stays at the application default 25; the Ubuntu/VBox installer writes the value # measured on its reference VM. See docs/CAPACITY_MODEL.md before raising. - SIMULATION_ADMISSION_MAX=${SIMULATION_ADMISSION_MAX:-25} - - SIMULATION_MAX_CONCURRENT=200 + - SIMULATION_MAX_CONCURRENT=${SIMULATION_MAX_CONCURRENT:-200} + # Empty keeps the application default (60000 ms, server/config.ts); a value + # from .env or a reviewed capacity.env proposal is passed through unchanged. + - SIMULATION_QUEUE_TIMEOUT_MS=${SIMULATION_QUEUE_TIMEOUT_MS:-} # Total paused time of one run; a paused run still holds its capacity. - SIMULATION_MAX_PAUSED_SECONDS=${SIMULATION_MAX_PAUSED_SECONDS:-600} - SANDBOX_POOL_IDLE_TIMEOUT_MS=300000 @@ -86,6 +89,10 @@ services: - "${UNOSIM_BIND_ADDRESS:-127.0.0.1}:3000:3000" volumes: - /var/run/docker.sock:/var/run/docker.sock + # The backend runs as the image's unprivileged `node` user (UID/GID 1000). + # Run `sudo ./scripts/prepare-runtime-dirs.sh` once before the first start: + # otherwise Docker creates missing mount sources as root:root and the + # backend cannot write its compile cache, temp files or storage (EACCES). - ${PWD}/server/arduino-cache:${PWD}/server/arduino-cache - ${PWD}/temp:${PWD}/temp - ./storage:/app/storage diff --git a/docs/CAPACITY_MODEL.md b/docs/CAPACITY_MODEL.md index d544f1f8d..63340c8e2 100644 --- a/docs/CAPACITY_MODEL.md +++ b/docs/CAPACITY_MODEL.md @@ -97,7 +97,7 @@ absent: The application defaults above come from server/config.ts. The Compose files may supply explicit deployment values: the current production compose file -sets SIMULATION_MAX_CONCURRENT=200, while the deployment fixture uses 1 for +defaults SIMULATION_MAX_CONCURRENT to 200 (overridable through `.env`), while the deployment fixture uses 1 for isolated deployment tests. Those are deployment policies, not application defaults, and this documentation does not change them. A target host should use an explicitly reviewed operating point rather than inheriting a test or @@ -566,7 +566,10 @@ default `capacity-test-results/calibration-/`): measured recommendations. It is intentionally allowed to be incomplete: omitted values can mean `not calibrated`, `infeasible`, `out of range`, or insufficient measurement evidence. It is not necessarily a complete - deployment configuration and is never applied automatically. + deployment configuration and is never applied automatically. The production + `docker-compose.yml` consumes every variable of the file from `.env`; verify + the effective values under `capacity` in `/api/status` after recreating the + backend container. If `SIMULATION_QUEUE_TIMEOUT_MS` is omitted because the queue result is infeasible, do not blindly apply the remaining lines while leaving an old queue diff --git a/docs/INSTALL_SERVER.md b/docs/INSTALL_SERVER.md index 00fd58dee..95bc29490 100644 --- a/docs/INSTALL_SERVER.md +++ b/docs/INSTALL_SERVER.md @@ -21,6 +21,9 @@ keinen host-nativen Simulations-Fallback. docker build -f Dockerfile.sandbox -t unosim-sandbox:latest . docker build -t unosim-server:latest . +# Einmalig vor dem ersten Start: Laufzeitverzeichnisse für den Container-User. +sudo ./scripts/prepare-runtime-dirs.sh + export DOCKER_GID="$(stat -c '%g' /var/run/docker.sock)" export UNOSIM_GATEWAY_SECRET="" export UNOSIM_TRUSTED_PROXY="" @@ -29,6 +32,16 @@ export UNOSIM_ALLOWED_WS_ORIGINS="https://classroom.example.edu" docker compose up --build ``` +Der Backend-Container läuft als unprivilegierter Benutzer `node` (UID/GID +1000), nicht als Benutzer des Hosts. Die drei eingebundenen Verzeichnisse +`server/arduino-cache`, `temp` und `storage` müssen ihm gehören. +`scripts/prepare-runtime-dirs.sh` legt sie an und übergibt sie an UID/GID 1000 +(Modus `0750`, kein `chmod 777`, Backend nicht als root). Ohne diesen Schritt +legt Docker fehlende Verzeichnisse als `root:root` an, und jeder Compile endet +mit `EACCES` und `POST /api/compile 500`. Das Skript ist idempotent, repariert +auch bereits root-eigene Inhalte und verlangt `sudo`; der Ubuntu/VBox-Installer +ruft es selbst auf. Die UID des Host-Benutzers spielt keine Rolle. + `docker-compose.yml` setzt `NODE_ENV=production` und `UNOSIM_SERVER_MODE=docker`. Das Profil aktiviert die Prüfung der erforderlichen Gateway-Identität und Docker-Simulation automatisch. Der Server prüft @@ -199,12 +212,12 @@ muss diese Werte nicht ändern. Für größere Installationen können sie über |---|---| | `WORKER_COUNT` | Anzahl paralleler Compile-Worker für `/api/compile`; höchstens 8 werden gestartet (ein höherer Wert wird beim Start als Warnung gemeldet) | | `COMPILE_MAX_CONCURRENT` | Obergrenze gleichzeitiger Compiles im Hauptprozess (lokaler Modus und Fallback bei ausgefallenem Worker-Pool) | -| `SIMULATION_MAX_CONCURRENT` | maximale Zahl gleichzeitig aktiver Simulationen | +| `SIMULATION_MAX_CONCURRENT` | maximale Zahl gleichzeitig aktiver Simulationen; Standard in Compose `200` | | `SANDBOX_START_MAX_CONCURRENT` | maximale Zahl paralleler Docker-Sandbox-Starts; Standard `8` | | `SANDBOX_START_SLOT_TIMEOUT_MS` | maximale Wartezeit eines zugelassenen Starts auf einen Sandbox-Startplatz; Standard `30000` ms | | `DOCKER_CONTROL_TIMEOUT_MS` | Timeout für kurze Docker-Verfügbarkeits- und Steuerbefehle wie `docker info`; Standard `2000` ms | | `SIMULATION_ADMISSION_MAX` | maximale Zahl zugelassener aktiver und wartender Anforderungen | -| `SIMULATION_QUEUE_TIMEOUT_MS` | Wartezeit einer zugelassenen Anforderung auf eine Simulationskapazität | +| `SIMULATION_QUEUE_TIMEOUT_MS` | Wartezeit einer zugelassenen Anforderung auf eine Simulationskapazität; Standard `60000` ms (Anwendungsstandard) | | `SANDBOX_MEMORY_MB` | Memory-Limit pro Sandbox | | `SANDBOX_CPU_LIMIT` | CPU-Limit pro Sandbox | | `UNOSIM_GITHUB_TOKEN` | optionaler Read-only-Token nur für `api.github.com` (Ref-Auflösung der Kursinhalte); ohne Token gilt das GitHub-Limit von 60 Abfragen pro Stunde und Server-IP | @@ -216,7 +229,7 @@ Wie viele Simulationen gleichzeitig laufen, begrenzen zwei Werte gemeinsam: - `SIMULATION_MAX_CONCURRENT` begrenzt die gleichzeitig laufenden Simulationen auf dem Server. Die Anwendung setzt standardmäßig `5`; `docker-compose.yml` - überschreibt das mit `200`. + verwendet `200`, sofern `.env` keinen Wert setzt. - `SIMULATION_ADMISSION_MAX` begrenzt laufende **und** wartende Simulationen zusammen. Der Standard ist `25`. @@ -253,7 +266,13 @@ Die Ergebnisse sind host-spezifisch und hängen unter anderem von Docker, Sandbox-CPU-/Memory-Limits und dem tatsächlichen Workload ab. `capacity.env` ist nur ein Review-Vorschlag; fehlende Werte müssen geprüft und ausdrücklich aufgelöst werden. Die Kalibrierung ändert keine Produktionskonfiguration -automatisch. Die technische Referenz mit Messphasen, Timeout-Grenzen und +automatisch. + +Übernommene `capacity.env`-Werte wirken erst, wenn sie in `.env` stehen und +`docker compose up -d` den Backend-Container neu erzeugt hat; Compose reicht +alle sieben Variablen der Datei an das Backend durch. Danach zeigt +`/api/status` unter `capacity` die tatsächlich wirksamen Werte, z. B. +`queue.timeoutMs`. Die technische Referenz mit Messphasen, Timeout-Grenzen und Akzeptanzkriterien steht in [`CAPACITY_MODEL.md`](CAPACITY_MODEL.md). diff --git a/docs/INSTALL_UBUNTU_VBOX.md b/docs/INSTALL_UBUNTU_VBOX.md index 44ceec503..56f46e4b8 100644 --- a/docs/INSTALL_UBUNTU_VBOX.md +++ b/docs/INSTALL_UBUNTU_VBOX.md @@ -98,6 +98,11 @@ Das Skript bindet das Backend an `127.0.0.1:3000`. LAN-Clients erreichen es über Nginx auf TCP-Port 443. Der alternative HTTPS-Port 8443 bleibt ebenfalls aktiv. +Die Laufzeitverzeichnisse `server/arduino-cache`, `temp` und `storage` gehören +nach der Installation der Container-UID/GID 1000 (`scripts/prepare-runtime-dirs.sh`, +Modus `0750`). Das ist unabhängig von der UID des installierenden Benutzers; der +Backend-Container läuft nicht als root. + Das Skript fügt den Benutzer der Gruppe `docker` hinzu. Die Änderung wird nach dem nächsten Ab- und Anmelden wirksam. Bis dahin verwendet das Skript `sudo` für Docker-Befehle; du musst `newgrp` nicht installieren. diff --git a/scripts/deployment/installer-deployment.ts b/scripts/deployment/installer-deployment.ts index 59d1aaa9a..696b58d34 100644 --- a/scripts/deployment/installer-deployment.ts +++ b/scripts/deployment/installer-deployment.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import WebSocket from "ws"; @@ -103,18 +103,65 @@ async function untrustedTutorRequest(network: string, image: string, env: Env): return JSON.parse(result.stdout.trim().split("\n").at(-1) ?? "{}"); } +/** + * Capacity values an operator applies from a reviewed capacity.env proposal by + * appending them to .env. Distinct from every default, so the assertions below + * prove that Compose passes them to the backend. + */ +const appliedCapacityEnv: Env = { + SIMULATION_MAX_CONCURRENT: "96", + SIMULATION_QUEUE_TIMEOUT_MS: "151000", +}; + +const runtimeDirectories = ["server/arduino-cache", "temp", "storage"]; +const prepareScript = join(repoRoot, "scripts/prepare-runtime-dirs.sh"); + +/** + * Runs the production preparation script the installer calls through sudo. It + * runs as root inside the server image, the way sudo runs it on a host, and + * leaves the directories to the image's own user. The runner user that owns + * projectDir is not that user (UID 1001 on GitHub runners), so this proves the + * preparation does not depend on the host account's UID. + */ +async function prepareRuntimeDirectories(projectDir: string, serverImage: string): Promise { + const [uid, gid] = await Promise.all(["-u", "-g"].map(async (flag) => (await checked("docker", [ + "run", "--rm", "--entrypoint", "id", serverImage, flag, + ], { timeoutMs: 60_000 })).stdout.trim())); + const source = await readFile(prepareScript, "utf8"); + const defaults = `${/^RUNTIME_UID='(\d+)'$/m.exec(source)?.[1]}:${/^RUNTIME_GID='(\d+)'$/m.exec(source)?.[1]}`; + assert.equal(defaults, `${uid}:${gid}`, "prepare-runtime-dirs.sh must default to the UID:GID of the image's user"); + + await checked("docker", [ + "run", "--rm", "--user", "0", + "-v", `${projectDir}:/project`, + "-v", `${prepareScript}:/prepare-runtime-dirs.sh:ro`, + "--entrypoint", "bash", serverImage, "/prepare-runtime-dirs.sh", "--project-dir", "/project", + ], { timeoutMs: 60_000 }); +} + +/** The backend user must be able to write every bind-mounted runtime directory. */ +async function assertRuntimeDirectoriesWritable(container: string): Promise { + const probe = await checked("docker", [ + "exec", container, "sh", "-c", + 'id -u; for d in "$ARDUINO_CACHE_DIR" "$UNOSIM_SHARED_TEMP_DIR" /app/storage; do ' + + 'f="$d/.deployment-write-probe"; touch "$f" && rm -f "$f" && echo "writable $d" || echo "NOT WRITABLE $d"; done', + ], { timeoutMs: 30_000 }); + const lines = probe.stdout.trim().split("\n"); + assert.notEqual(lines[0], "0", "the backend must not run as root"); + assert.equal(lines.filter((line) => line.startsWith("writable ")).length, runtimeDirectories.length, + `backend user cannot write all runtime directories:\n${probe.stdout}`); +} + /** Writes the installer-rendered .env, Nginx site, secret snippet and TLS files. */ async function renderInstallerProject( projectDir: string, nginxDir: string, tlsDir: string, + serverImage: string, ): Promise<{ installerEnv: Env; ca: Buffer }> { - // Same directories the installer creates; container user and runner user - // differ in CI, so the bind-mounted runtime directories must be writable. - for (const directory of ["server/arduino-cache", "temp", "storage"]) { - await mkdir(join(projectDir, directory), { recursive: true }); - await chmod(join(projectDir, directory), 0o777); - } + // The installer creates these through prepare-runtime-dirs.sh; the checkout + // owner and the container user differ, so a plain mkdir is not enough. + await prepareRuntimeDirectories(projectDir, serverImage); await mkdir(tlsDir, { recursive: true }); const gatewaySecret = (await checked("openssl", ["rand", "-hex", "32"])).stdout.trim(); @@ -122,9 +169,10 @@ async function renderInstallerProject( renderer, "env", "--lan-ip", lanIp, "--docker-gid", await resolveDockerGid(), ], { input: `${gatewaySecret}\n` })).stdout; const override = process.env.DEPLOYMENT_TRUSTED_PROXY_OVERRIDE; - const dotEnv = override + const gatewayEnv = override ? renderedEnv.replace(/^UNOSIM_TRUSTED_PROXY=.*$/m, `UNOSIM_TRUSTED_PROXY=${override}`) : renderedEnv; + const dotEnv = `${gatewayEnv}${Object.entries(appliedCapacityEnv).map(([key, value]) => `${key}=${value}\n`).join("")}`; await writeFile(join(projectDir, ".env"), dotEnv, { mode: 0o600 }); const installerEnv = parseDotEnv(dotEnv); await assertSubnetUnused(installerEnv.UNOSIM_DOCKER_SUBNET); @@ -182,6 +230,15 @@ async function verifyTrustChain( assert.equal(admissionMax, Number(installerEnv.SIMULATION_ADMISSION_MAX), `backend admission limit ${admissionMax} does not match the installer value ${installerEnv.SIMULATION_ADMISSION_MAX}`); + // E. Capacity values applied through .env reach the backend: Compose used to + // ignore SIMULATION_QUEUE_TIMEOUT_MS and pin SIMULATION_MAX_CONCURRENT to 200. + const capacity = (JSON.parse(spoofed.body) as { + capacity?: { queue?: { timeoutMs?: number }; simulation?: { maxConcurrent?: number } }; + }).capacity; + assert.equal(capacity?.queue?.timeoutMs, Number(appliedCapacityEnv.SIMULATION_QUEUE_TIMEOUT_MS), + `backend queue timeout ${capacity?.queue?.timeoutMs} does not match .env SIMULATION_QUEUE_TIMEOUT_MS`); + assert.equal(capacity?.simulation?.maxConcurrent, Number(appliedCapacityEnv.SIMULATION_MAX_CONCURRENT), + `backend simulation limit ${capacity?.simulation?.maxConcurrent} does not match .env SIMULATION_MAX_CONCURRENT`); } /** Installer origins, WebSocket and a sandboxed simulation through the gateway. */ @@ -264,7 +321,7 @@ async function assertRuntimeMounts(container: string, projectDir: string): Promi const mounts = (await checked("docker", [ "inspect", container, "--format", "{{range .Mounts}}{{.Source}}\n{{end}}", ], { timeoutMs: 20_000 })).stdout.split("\n").filter(Boolean); - for (const directory of ["server/arduino-cache", "temp", "storage"]) { + for (const directory of runtimeDirectories) { assert.ok(mounts.includes(join(projectDir, directory)), `backend must mount ${join(projectDir, directory)} (mounts: ${mounts.join(", ")})`); } } @@ -305,7 +362,7 @@ async function main(): Promise { for (const port of [3000, 443, gatewayPort]) await assertPortFree(port); images = await prepareImages(runId); - const { installerEnv, ca } = await renderInstallerProject(projectDir, nginxDir, tlsDir); + const { installerEnv, ca } = await renderInstallerProject(projectDir, nginxDir, tlsDir, images.server); const gateway = { port: gatewayPort, ca }; composeEnv = { @@ -328,6 +385,7 @@ async function main(): Promise { await compose(["up", "-d", "--no-build"], 240_000); await assertRuntimeMounts(backendContainer, projectDir); + await assertRuntimeDirectoriesWritable(backendContainer); const network = `${projectName}_default`; const networkGateway = (await checked("docker", [ "network", "inspect", network, "--format", "{{range .IPAM.Config}}{{.Gateway}}{{end}}", diff --git a/scripts/install-ubuntu-vbox.sh b/scripts/install-ubuntu-vbox.sh index 97f540c00..9ffe14265 100755 --- a/scripts/install-ubuntu-vbox.sh +++ b/scripts/install-ubuntu-vbox.sh @@ -94,6 +94,8 @@ PROJECT_DIR="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null)" || f [[ -f "$PROJECT_DIR/docker-compose.yml" ]] || fail 'docker-compose.yml fehlt in der UnoSim-Arbeitskopie.' RENDER="$SCRIPT_DIR/ubuntu-vbox-render.sh" [[ -f "$RENDER" ]] || fail 'scripts/ubuntu-vbox-render.sh fehlt in der UnoSim-Arbeitskopie.' +PREPARE_RUNTIME_DIRS="$SCRIPT_DIR/prepare-runtime-dirs.sh" +[[ -f "$PREPARE_RUNTIME_DIRS" ]] || fail 'scripts/prepare-runtime-dirs.sh fehlt in der UnoSim-Arbeitskopie.' ENV_FILE="$PROJECT_DIR/.env" NGINX_SITE="/etc/nginx/sites-available/unosim" @@ -149,8 +151,11 @@ sudo usermod -aG docker "$INSTALL_USER" sudo docker info >/dev/null || fail 'Der Docker-Daemon ist nicht erreichbar.' -# These directories are bind-mounted into the backend container at runtime. -mkdir -p "$PROJECT_DIR/server/arduino-cache" "$PROJECT_DIR/temp" "$PROJECT_DIR/storage" +# These directories are bind-mounted into the backend container at runtime. The +# container runs as its unprivileged node user (UID/GID 1000), which is not the +# installing user, so they must belong to it; docker would otherwise create them +# as root:root and every compile would fail with EACCES. +sudo bash "$PREPARE_RUNTIME_DIRS" --project-dir "$PROJECT_DIR" cd "$PROJECT_DIR" printf 'Baue das UnoSim-Sandbox-Image …\n' diff --git a/scripts/prepare-runtime-dirs.sh b/scripts/prepare-runtime-dirs.sh new file mode 100755 index 000000000..8a27a5ae1 --- /dev/null +++ b/scripts/prepare-runtime-dirs.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Prepares the host directories that docker-compose.yml bind-mounts into the +# backend container so that the container's unprivileged user can write to them. +# +# sudo ./scripts/prepare-runtime-dirs.sh [--project-dir DIR] [--uid UID] [--gid GID] +# +# The backend image runs as its `node` user, numeric UID/GID 1000 (Dockerfile). +# The directories are handed to that numeric ID and not to a host account: the +# host user may have any UID (1001 on a second account, for example), and a +# missing mount source would otherwise be created by Docker as root:root. +# Mode 0750 keeps the directories closed to other host users; there is no +# world-writable mode and the backend never needs to run as root. +set -Eeuo pipefail + +# Keep in sync with the `node` user of the unosim-server image. The installer +# deployment test compares these values with `id` inside the built image. +RUNTIME_UID='1000' +RUNTIME_GID='1000' +RUNTIME_DIRECTORIES=(server/arduino-cache temp storage) + +usage() { + cat <<'USAGE' +Usage: sudo ./scripts/prepare-runtime-dirs.sh [--project-dir DIR] [--uid UID] [--gid GID] + +Creates server/arduino-cache, temp and storage below the project directory +(default: this checkout) and gives them to the backend container user +(default UID/GID 1000). Run it before the first `docker compose up`. +USAGE +} + +fail() { + printf 'Fehler: %s\n' "$1" >&2 + exit 1 +} + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +project_dir="$(dirname -- "$SCRIPT_DIR")" +uid="$RUNTIME_UID" +gid="$RUNTIME_GID" + +while (($# > 0)); do + case "$1" in + --project-dir) + (($# >= 2)) || fail 'Nach --project-dir fehlt das Verzeichnis.' + project_dir="$2" + shift 2 + ;; + --uid) + (($# >= 2)) || fail 'Nach --uid fehlt die numerische Benutzer-ID.' + uid="$2" + shift 2 + ;; + --gid) + (($# >= 2)) || fail 'Nach --gid fehlt die numerische Gruppen-ID.' + gid="$2" + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + *) + usage >&2 + fail "Unbekanntes Argument: $1" + ;; + esac +done + +[[ "$uid" =~ ^[0-9]+$ ]] || fail '--uid muss eine numerische Benutzer-ID sein.' +[[ "$gid" =~ ^[0-9]+$ ]] || fail '--gid muss eine numerische Gruppen-ID sein.' +[[ -d "$project_dir" ]] || fail "Projektverzeichnis nicht gefunden: $project_dir" + +# Handing a directory to another account needs root; the account that already +# is the target may prepare its own directories without it. +if [[ "$(id -u)" -ne 0 && ( "$(id -u)" != "$uid" || "$(id -g)" != "$gid" ) ]]; then + fail "Die Verzeichnisse gehen an UID:GID ${uid}:${gid}; starte das Skript dafür mit sudo." +fi + +# Validate everything before the first change so a refusal leaves no partial state. +for relative in "${RUNTIME_DIRECTORIES[@]}"; do + target="$project_dir/$relative" + if [[ -L "$target" ]]; then + fail "$target ist ein symbolischer Link; das Skript ändert keine Ziele außerhalb des Projekts." + fi + if [[ -e "$target" && ! -d "$target" ]]; then + fail "$target existiert, ist aber kein Verzeichnis." + fi +done + +for relative in "${RUNTIME_DIRECTORIES[@]}"; do + target="$project_dir/$relative" + mkdir -p -- "$target" + # -h: never follow a link planted inside the tree. This also repairs content + # that an earlier container start left behind as root:root. + chown -hR -- "${uid}:${gid}" "$target" + chmod -- 0750 "$target" + printf 'Bereit für UID:GID %s:%s: %s\n' "$uid" "$gid" "$target" +done diff --git a/tests/server/compose-capacity-passthrough.test.ts b/tests/server/compose-capacity-passthrough.test.ts new file mode 100644 index 000000000..a8db75b47 --- /dev/null +++ b/tests/server/compose-capacity-passthrough.test.ts @@ -0,0 +1,98 @@ +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; +import { renderCapacityEnv } from "../../scripts/capacity-calibration-report"; +import type { CalibrationRunResult } from "../../scripts/calibrate-capacity"; +import { parseEnvInt } from "../../server/config"; + +const repoRoot = join(__dirname, "../.."); +const composePath = join(repoRoot, "docker-compose.yml"); + +/** Variable names the calibration proposes in capacity.env, taken from the report renderer itself. */ +function capacityEnvVariables(): string[] { + const notCalibrated = { value: null, status: "not-calibrated", confidence: "LOW", measuredBasis: [], warnings: [] }; + const result = { + policyVersion: "1", + policy: { expectedUsers: 1 }, + fingerprint: { gitSha: "test" }, + phases: { dockerControl: [], active: [], startup: [], classroom: null }, + recommendations: Object.fromEntries([ + "simulationMaxConcurrent", "sandboxStartMaxConcurrent", "simulationAdmissionMax", "simulationQueueTimeoutMs", + "sandboxStartSlotTimeoutMs", "dockerControlTimeoutMs", "compileMaxConcurrent", + ].map((name) => [name, notCalibrated])), + } as unknown as CalibrationRunResult; + return [...renderCapacityEnv(result).matchAll(/^#?\s*([A-Z][A-Z0-9_]+)(?:=| omitted)/gm)].map((match) => match[1]); +} + +function composeBackendEnvironment(): string[] { + const compose = parse(readFileSync(composePath, "utf8")) as { services: Record }; + return compose.services["unosim-backend"].environment; +} + +const composeAvailable = spawnSync("docker", ["compose", "version"], { stdio: "ignore" }).status === 0; + +/** Environment of the backend service after real Compose interpolation of docker-compose.yml. */ +function interpolatedBackendEnvironment(overrides: Record): Record { + const result = spawnSync("docker", ["compose", "-f", composePath, "config", "--format", "json"], { + encoding: "utf8", + cwd: repoRoot, + // Only what Compose needs: host UnoSim settings must not shadow the values under test. + env: { + PATH: process.env.PATH ?? "", + HOME: process.env.HOME ?? "", + PWD: repoRoot, + DOCKER_GID: "999", + UNOSIM_GATEWAY_SECRET: "g".repeat(32), + UNOSIM_TRUSTED_PROXY: "127.0.0.1", + UNOSIM_ALLOWED_WS_ORIGINS: "https://classroom.example.edu", + ...overrides, + }, + }); + expect(result.status, result.stderr).toBe(0); + const parsed = JSON.parse(result.stdout) as { services: Record }> }; + return parsed.services["unosim-backend"].environment; +} + +describe("docker-compose capacity pass-through", () => { + it("reads the capacity.env variable list from the report renderer", () => { + expect(capacityEnvVariables().sort()).toEqual([ + "COMPILE_MAX_CONCURRENT", + "DOCKER_CONTROL_TIMEOUT_MS", + "SANDBOX_START_MAX_CONCURRENT", + "SANDBOX_START_SLOT_TIMEOUT_MS", + "SIMULATION_ADMISSION_MAX", + "SIMULATION_MAX_CONCURRENT", + "SIMULATION_QUEUE_TIMEOUT_MS", + ]); + }); + + it("forwards every variable of a capacity.env proposal from .env to the backend container", () => { + const environment = composeBackendEnvironment(); + for (const name of capacityEnvVariables()) { + const entry = environment.find((candidate) => candidate.startsWith(`${name}=`)); + expect(entry, `${name} must be passed to unosim-backend`).toBeDefined(); + expect(entry, `${name} must take its value from .env`).toMatch(new RegExp(String.raw`^${name}=\$\{${name}(:-[^}]*)?\}$`)); + } + }); + + it.skipIf(!composeAvailable)("delivers .env values to the container after real Compose interpolation", () => { + const names = capacityEnvVariables(); + const overrides = Object.fromEntries(names.map((name, index) => [name, String(1_000 + index)])); + + const environment = interpolatedBackendEnvironment(overrides); + + for (const name of names) expect(environment[name], name).toBe(overrides[name]); + }); + + it.skipIf(!composeAvailable)("keeps the previous defaults when .env sets nothing", () => { + const environment = interpolatedBackendEnvironment({}); + + expect(environment.SIMULATION_MAX_CONCURRENT).toBe("200"); + expect(environment.SIMULATION_ADMISSION_MAX).toBe("25"); + // Not a second default: empty falls back to the application default in server/config.ts. + expect(environment.SIMULATION_QUEUE_TIMEOUT_MS).toBe(""); + expect(parseEnvInt("SIMULATION_QUEUE_TIMEOUT_MS", environment.SIMULATION_QUEUE_TIMEOUT_MS, 60_000)).toBe(60_000); + }); +}); diff --git a/tests/server/prepare-runtime-dirs.test.ts b/tests/server/prepare-runtime-dirs.test.ts new file mode 100644 index 000000000..6015769e6 --- /dev/null +++ b/tests/server/prepare-runtime-dirs.test.ts @@ -0,0 +1,137 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { accessSync, constants, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { parse } from "yaml"; + +const repoRoot = join(__dirname, "../.."); +const script = join(repoRoot, "scripts/prepare-runtime-dirs.sh"); +const runtimeDirectories = ["server/arduino-cache", "temp", "storage"]; +const temporaryDirectories: string[] = []; + +function projectDir(): string { + const directory = mkdtempSync(join(tmpdir(), "unosim-runtime-dirs-")); + temporaryDirectories.push(directory); + return directory; +} + +/** Runs the script as the current user, targeting that same account (no root needed). */ +function prepareForCurrentUser(directory: string, extraArguments: string[] = []) { + return spawnSync("bash", [ + script, "--project-dir", directory, "--uid", String(process.getuid?.()), "--gid", String(process.getgid?.()), ...extraArguments, + ], { encoding: "utf8" }); +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) rmSync(directory, { recursive: true, force: true }); +}); + +describe("prepare-runtime-dirs.sh", () => { + it("creates the runtime directories writable for the target account and closed to everyone else", () => { + const directory = projectDir(); + + const result = prepareForCurrentUser(directory); + + expect(result.status, result.stderr).toBe(0); + for (const relative of runtimeDirectories) { + const target = join(directory, relative); + const stats = statSync(target); + expect(stats.isDirectory(), relative).toBe(true); + // The target is the current account, which is not UID 1000 on every host (1001 in the field). + expect(stats.uid, relative).toBe(process.getuid?.()); + expect(stats.mode & 0o777, relative).toBe(0o750); + accessSync(target, constants.W_OK); + writeFileSync(join(target, "probe"), "x"); + } + }); + + it("is idempotent and keeps existing content", () => { + const directory = projectDir(); + mkdirSync(join(directory, "storage/core-cache"), { recursive: true }); + writeFileSync(join(directory, "storage/core-cache/index"), "cached"); + + expect(prepareForCurrentUser(directory).status).toBe(0); + expect(prepareForCurrentUser(directory).status).toBe(0); + + expect(readFileSync(join(directory, "storage/core-cache/index"), "utf8")).toBe("cached"); + }); + + it.skipIf(process.getuid?.() === 0)("asks for sudo instead of failing halfway when it cannot hand the directories over", () => { + const directory = projectDir(); + const foreignUid = String((process.getuid?.() ?? 0) + 1); + + const result = spawnSync("bash", [script, "--project-dir", directory, "--uid", foreignUid, "--gid", foreignUid], { encoding: "utf8" }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/sudo/); + for (const relative of runtimeDirectories) expect(() => statSync(join(directory, relative))).toThrow(); + }); + + it("refuses a symbolic link and changes nothing", () => { + const directory = projectDir(); + const elsewhere = projectDir(); + mkdirSync(join(directory, "server"), { recursive: true }); + symlinkSync(elsewhere, join(directory, "server/arduino-cache")); + + const result = prepareForCurrentUser(directory); + + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/symbolischer Link/); + expect(() => statSync(join(directory, "temp"))).toThrow(); + expect(statSync(elsewhere).mode & 0o777).not.toBe(0o750); + }); + + it("refuses a regular file in place of a directory", () => { + const directory = projectDir(); + writeFileSync(join(directory, "temp"), ""); + + const result = prepareForCurrentUser(directory); + + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/kein Verzeichnis/); + }); + + it("rejects non-numeric IDs", () => { + const result = spawnSync("bash", [script, "--project-dir", projectDir(), "--uid", "node"], { encoding: "utf8" }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/numerische/); + }); +}); + +describe("runtime directory deployment contract", () => { + it("prepares exactly the directories docker-compose.yml bind-mounts, relative to the checkout", () => { + const compose = parse(readFileSync(join(repoRoot, "docker-compose.yml"), "utf8")) as { + services: { "unosim-backend": { volumes: string[] } }; + }; + const mounted = compose.services["unosim-backend"].volumes + .filter((volume) => !volume.startsWith("/var/run/docker.sock")) + .map((volume) => volume.split(":")[0].replace(/^\$\{PWD\}\//, "").replace(/^\.\//, "")) + .sort(); + const listed = readFileSync(script, "utf8").match(/^RUNTIME_DIRECTORIES=\(([^)]*)\)/m)?.[1].trim().split(/\s+/).sort(); + + expect(listed).toEqual(mounted); + expect(mounted).toEqual([...runtimeDirectories].sort()); + }); + + it("defaults to the UID/GID of the unprivileged user the image runs as", () => { + const source = readFileSync(script, "utf8"); + const dockerfile = readFileSync(join(repoRoot, "Dockerfile"), "utf8"); + + expect(source).toMatch(/^RUNTIME_UID='1000'$/m); + expect(source).toMatch(/^RUNTIME_GID='1000'$/m); + expect(dockerfile).toMatch(/^USER node$/m); + expect(dockerfile).not.toMatch(/^USER (root|0)\b/m); + expect(source).not.toMatch(/chmod\s+(-R\s+)?0?777|a\+w|o\+w/); + }); + + it("is what the installer uses instead of creating the directories as the installing user", () => { + const installer = readFileSync(join(repoRoot, "scripts/install-ubuntu-vbox.sh"), "utf8"); + + expect(installer).toMatch(/^sudo bash "\$PREPARE_RUNTIME_DIRS" --project-dir "\$PROJECT_DIR"$/m); + expect(installer).not.toMatch(/^mkdir -p .*arduino-cache/m); + execFileSync("bash", ["-n", script]); + expect(statSync(script).mode & 0o111).not.toBe(0); + }); +}); From f38c07172fee4991866996208c70909c2993e926 Mon Sep 17 00:00:00 2001 From: ttbombadil Date: Mon, 12 Oct 2026 17:37:16 +0200 Subject: [PATCH 2/3] refactor(deploy): avoid nested template literal in installer deployment test Sonar S4624 flagged the .env assembly added with the capacity pass-through assertions. Build the capacity lines first and concatenate. Co-Authored-By: Claude Sonnet 5.5 --- scripts/deployment/installer-deployment.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/deployment/installer-deployment.ts b/scripts/deployment/installer-deployment.ts index 696b58d34..62f21726e 100644 --- a/scripts/deployment/installer-deployment.ts +++ b/scripts/deployment/installer-deployment.ts @@ -172,7 +172,8 @@ async function renderInstallerProject( const gatewayEnv = override ? renderedEnv.replace(/^UNOSIM_TRUSTED_PROXY=.*$/m, `UNOSIM_TRUSTED_PROXY=${override}`) : renderedEnv; - const dotEnv = `${gatewayEnv}${Object.entries(appliedCapacityEnv).map(([key, value]) => `${key}=${value}\n`).join("")}`; + const capacityLines = Object.entries(appliedCapacityEnv).map(([key, value]) => `${key}=${value}\n`).join(""); + const dotEnv = gatewayEnv + capacityLines; await writeFile(join(projectDir, ".env"), dotEnv, { mode: 0o600 }); const installerEnv = parseDotEnv(dotEnv); await assertSubnetUnused(installerEnv.UNOSIM_DOCKER_SUBNET); From 6cf1c9975d018bafa0bda42799184dad9c6e67bf Mon Sep 17 00:00:00 2001 From: ttbombadil Date: Mon, 12 Oct 2026 18:08:49 +0200 Subject: [PATCH 3/3] fix(deploy): keep storage out of the Docker build context prepare-runtime-dirs.sh hands server/arduino-cache, temp and storage to the container user (UID/GID 1000, mode 0750), which another host account cannot read. server/arduino-cache and temp were already ignored; storage was not. A build step that copies the whole context (or a directory containing the mount) then fails with "open .../storage: permission denied". With the current Dockerfile BuildKit does not read storage, so this is a safeguard rather than a repair, but storage is runtime state and never belongs in an image. The regression test builds `COPY . /all` with the real .dockerignore as UID 1001 in a Linux container against directories owned by 1000 with mode 0750, and checks that the same build fails once a runtime directory is not ignored. A static test ties the ignore list to the compose bind mounts. Co-Authored-By: Claude Sonnet 5.5 --- .dockerignore | 6 + docs/INSTALL_SERVER.md | 6 + tests/server/docker-build-context.test.ts | 132 ++++++++++++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 tests/server/docker-build-context.test.ts diff --git a/.dockerignore b/.dockerignore index acb4edfc6..929ec21fd 100644 --- a/.dockerignore +++ b/.dockerignore @@ -21,3 +21,9 @@ server/arduino-cache # Temporary files temp *.log + +# Runtime state bind-mounted by docker-compose.yml. After +# scripts/prepare-runtime-dirs.sh it belongs to the container user (UID 1000, +# mode 0750), which the host user cannot read; sending it as build context +# would fail with "permission denied". It is never copied into an image. +storage diff --git a/docs/INSTALL_SERVER.md b/docs/INSTALL_SERVER.md index 95bc29490..171f4925e 100644 --- a/docs/INSTALL_SERVER.md +++ b/docs/INSTALL_SERVER.md @@ -42,6 +42,12 @@ mit `EACCES` und `POST /api/compile 500`. Das Skript ist idempotent, repariert auch bereits root-eigene Inhalte und verlangt `sudo`; der Ubuntu/VBox-Installer ruft es selbst auf. Die UID des Host-Benutzers spielt keine Rolle. +Danach kann der Host-Benutzer diese Verzeichnisse nicht mehr lesen. Deshalb +stehen `server/arduino-cache`, `temp` und `storage` in der `.dockerignore`: Sie +sind Laufzeitzustand, gehören nicht in ein Image und dürfen den Build-Kontext +nicht erreichen. Sonst scheitert `docker compose up --build` mit +`permission denied`, sobald ein Dockerfile-Schritt wie `COPY . …` sie einsammelt. + `docker-compose.yml` setzt `NODE_ENV=production` und `UNOSIM_SERVER_MODE=docker`. Das Profil aktiviert die Prüfung der erforderlichen Gateway-Identität und Docker-Simulation automatisch. Der Server prüft diff --git a/tests/server/docker-build-context.test.ts b/tests/server/docker-build-context.test.ts new file mode 100644 index 000000000..4e5fb8136 --- /dev/null +++ b/tests/server/docker-build-context.test.ts @@ -0,0 +1,132 @@ +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; + +const repoRoot = join(__dirname, "../.."); +const dockerignorePath = join(repoRoot, ".dockerignore"); +// The CLI image the production Dockerfile already depends on (first build stage). +const CLI_IMAGE = "docker:27-cli"; +// A host account that is not the container user (1000), like the field case UID 1001. +const HOST_USER = "1001:0"; + +/** Host paths docker-compose.yml bind-mounts into the backend, relative to the checkout. */ +function runtimeMountDirectories(): string[] { + const compose = parse(readFileSync(join(repoRoot, "docker-compose.yml"), "utf8")) as { + services: { "unosim-backend": { volumes: string[] } }; + }; + return compose.services["unosim-backend"].volumes + .filter((volume) => !volume.startsWith("/var/run/docker.sock")) + .map((volume) => volume.split(":")[0].replace(/^\$\{PWD\}\//, "").replace(/^\.\//, "")); +} + +function dockerignoreLines(content: string): string[] { + return content.split("\n").map((line) => line.trim()).filter((line) => line !== "" && !line.startsWith("#")); +} + +const dockerAvailable = spawnSync("docker", ["info"], { stdio: "ignore", timeout: 20_000 }).status === 0; + +function docker(args: string[], timeout = 120_000) { + return spawnSync("docker", args, { encoding: "utf8", timeout }); +} + +function cliImageAvailable(): boolean { + if (docker(["image", "inspect", CLI_IMAGE], 20_000).status === 0) return true; + return docker(["pull", "-q", CLI_IMAGE], 180_000).status === 0; +} + +/** + * Builds `COPY . /all` the way a host account other than the container user + * would: the Docker CLI runs as UID 1001 inside a Linux container and the + * runtime directories belong to UID/GID 1000 with mode 0750, exactly what + * prepare-runtime-dirs.sh leaves. Running in a container makes the permission + * semantics identical on Linux CI and on Docker Desktop. + */ +function buildWholeContextAsOtherUser(dockerignore: string): { status: number | null; output: string } { + const volume = `unosim-build-context-${process.pid}-${Date.now()}`; + const directories = runtimeMountDirectories(); + expect(docker(["volume", "create", volume], 30_000).status).toBe(0); + try { + const prepare = docker([ + "run", "--rm", "--user", "0", "-v", `${volume}:/ctx`, "-e", `IGNORE=${dockerignore}`, "-e", `DIRS=${directories.join(" ")}`, + "--entrypoint", "sh", CLI_IMAGE, "-c", + [ + "set -e", + String.raw`printf 'FROM scratch\nCOPY . /all\n' > /ctx/Dockerfile`, + "echo ok > /ctx/marker", + String.raw`printf '%s\n' "$IGNORE" > /ctx/.dockerignore`, + 'for d in $DIRS; do mkdir -p "/ctx/$d/nested"; echo state > "/ctx/$d/nested/file"; chown -R 1000:1000 "/ctx/$d"; chmod 0750 "/ctx/$d"; done', + "chmod 755 /ctx /ctx/server; chmod 644 /ctx/Dockerfile /ctx/marker /ctx/.dockerignore", + ].join("\n"), + ]); + expect(prepare.status, prepare.stderr).toBe(0); + + // The build container needs the daemon socket's group as seen inside containers. + const socketGroup = docker([ + "run", "--rm", "-v", "/var/run/docker.sock:/s", "--entrypoint", "stat", CLI_IMAGE, "-c", "%g", "/s", + ], 60_000).stdout.trim(); + const result = docker([ + "run", "--rm", "--user", HOST_USER, "--group-add", socketGroup, "-e", "HOME=/tmp", "-e", "DOCKER_CONFIG=/tmp/dc", + "-v", "/var/run/docker.sock:/var/run/docker.sock", "-v", `${volume}:/ctx`, + CLI_IMAGE, "build", "-q", "/ctx", + ], 150_000); + const imageId = /^sha256:[0-9a-f]+$/m.exec(result.stdout)?.[0]; + if (imageId) docker(["rmi", "-f", imageId], 30_000); + return { status: result.status, output: `${result.stdout}${result.stderr}` }; + } finally { + docker(["volume", "rm", "-f", volume], 30_000); + } +} + +describe("docker build context", () => { + it("lists the mounted runtime directories this suite checks", () => { + expect(runtimeMountDirectories().sort()).toEqual(["server/arduino-cache", "storage", "temp"]); + }); + + it("excludes every bind-mounted runtime directory, as a whole and relative to the context root", () => { + const patterns = dockerignoreLines(readFileSync(dockerignorePath, "utf8")); + + for (const directory of runtimeMountDirectories()) { + expect(patterns, `${directory} must be ignored`).toContain(directory); + } + // A negation could re-include part of a runtime directory. + expect(patterns.filter((pattern) => pattern.startsWith("!"))).toEqual([]); + }); + + it("does not copy any runtime directory into an image", () => { + const dockerfile = readFileSync(join(repoRoot, "Dockerfile"), "utf8"); + const copied = [...dockerfile.matchAll(/^COPY\s+(?!--from)(.+)\s+\S+$/gm)].flatMap((match) => match[1].split(/\s+/)); + + for (const directory of runtimeMountDirectories()) { + expect(copied.some((source) => source === directory || source.startsWith(`${directory}/`)), directory).toBe(false); + } + }); + + it.skipIf(!dockerAvailable)( + "keeps runtime state owned by another UID out of a whole-context COPY with the real .dockerignore", + (context) => { + if (!cliImageAvailable()) context.skip(); + + const result = buildWholeContextAsOtherUser(readFileSync(dockerignorePath, "utf8")); + + expect(result.status, result.output).toBe(0); + }, + 300_000, + ); + + it.skipIf(!dockerAvailable)( + "control: the same build fails with 'permission denied' once a runtime directory is not ignored", + (context) => { + if (!cliImageAvailable()) context.skip(); + const runtime = new Set(runtimeMountDirectories()); + const withoutRuntime = dockerignoreLines(readFileSync(dockerignorePath, "utf8")).filter((pattern) => !runtime.has(pattern)); + + const result = buildWholeContextAsOtherUser(withoutRuntime.join("\n")); + + expect(result.status).not.toBe(0); + expect(result.output).toMatch(/permission denied/i); + }, + 300_000, + ); +});