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/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..171f4925e 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,22 @@ 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. + +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 @@ -199,12 +218,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 +235,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 +272,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..62f21726e 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,11 @@ 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 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); @@ -182,6 +231,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 +322,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 +363,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 +386,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/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, + ); +}); 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); + }); +});