Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 8 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions docs/CAPACITY_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -566,7 +566,10 @@ default `capacity-test-results/calibration-<timestamp>/`):
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
Expand Down
33 changes: 29 additions & 4 deletions docs/INSTALL_SERVER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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="<mindestens-32-zufaellige-Zeichen>"
export UNOSIM_TRUSTED_PROXY="<gateway-ip-oder-cidr>"
Expand All @@ -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
Expand Down Expand Up @@ -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 |
Expand All @@ -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`.

Expand Down Expand Up @@ -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).

Expand Down
5 changes: 5 additions & 0 deletions docs/INSTALL_UBUNTU_VBOX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
79 changes: 69 additions & 10 deletions scripts/deployment/installer-deployment.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -103,28 +103,77 @@ 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<void> {
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<void> {
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();
const renderedEnv = (await checked("bash", [
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);
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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(", ")})`);
}
}
Expand Down Expand Up @@ -305,7 +363,7 @@ async function main(): Promise<void> {
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 = {
Expand All @@ -328,6 +386,7 @@ async function main(): Promise<void> {
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}}",
Expand Down
9 changes: 7 additions & 2 deletions scripts/install-ubuntu-vbox.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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'
Expand Down
Loading
Loading