openvibers/openvibe-git Commits
Forge runner: make the five real repositories pass (#34)
Changes
@@@ -16,7 +16,7 @@},"openvibers/openvibe.games": {"image": "localhost/openvibe-ci-node22:1",-- "install_command": "pnpm install --frozen-lockfile",++ "install_command": "pnpm install --frozen-lockfile --store-dir /workspace/node_modules/.pnpm-store","test_command": "pnpm typecheck && pnpm lint && pnpm test","timeout_seconds": 1800},@@@ -26,12 +26,14 @@"timeout_seconds": 1800},"openvibers/openvibe.chat": {++ "image": "localhost/openvibe-ci-node22-ffmpeg:1","install_command": "npm ci --no-audit --no-fund","test_command": "npm test","timeout_seconds": 1800},"openvibers/openvibe.live": {-- "install_command": "npm ci --no-audit --no-fund",++ "image": "localhost/openvibe-ci-node22-mediasoup:1",++ "install_command": "ONNXRUNTIME_NODE_INSTALL=skip npm ci --no-audit --no-fund","test_command": "npm test","timeout_seconds": 1800}@@@ -64,6 +64,27 @@ EOFprintf 'FROM docker.io/library/node:22-bookworm\nRUN npm install -g [email protected]\n' \| $PODMAN build --pull=never -t localhost/openvibe-ci-node22:1 -fi++ # Chat's sound tests convert audio, as GitHub's ffmpeg apt step does.++ if ! $PODMAN image exists localhost/openvibe-ci-node22-ffmpeg:1; then++ say "building localhost/openvibe-ci-node22-ffmpeg:1 (the pnpm image + ffmpeg)"++ printf 'FROM localhost/openvibe-ci-node22:1\nRUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/*\n' \++ | $PODMAN build --pull=never -t localhost/openvibe-ci-node22-ffmpeg:1 -++ fi++ # Live's mediasoup postinstall downloads a prebuilt worker from GitHub; Node's fetch has no proxy, so the++ # worker is baked into the image instead. kernel6 is the asset whose libstdc++ matches bookworm.++ if ! $PODMAN image exists localhost/openvibe-ci-node22-mediasoup:1; then++ say "building localhost/openvibe-ci-node22-mediasoup:1 (node 22 + the mediasoup worker)"++ cat <<'DOCKER' | $PODMAN build --pull=never -t localhost/openvibe-ci-node22-mediasoup:1 -++FROM docker.io/library/node:22-bookworm++RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \++ && mkdir -p /opt/mediasoup \++ && curl -fsSL https://github.com/versatica/mediasoup/releases/download/3.25.0/mediasoup-worker-3.25.0-linux-x64-kernel6.tgz -o /tmp/w.tgz \++ && tar -xzf /tmp/w.tgz -C /opt/mediasoup \++ && chmod 0755 /opt/mediasoup/mediasoup-worker \++ && rm -f /tmp/w.tgz && rm -rf /var/lib/apt/lists/*++ENV MEDIASOUP_WORKER_BIN=/opt/mediasoup/mediasoup-worker++DOCKER++ fielif command -v bwrap >/dev/null 2>&1; thensay "sandbox engine: bubblewrap $(bwrap --version | awk '{print $2}') (podman is not rootless here)"else@@@ -31,7 +31,9 @@ error when the runner reports; a timed-out run is a failure.while the mirror catches up with the webhook.3. **Install phase** — inside the sandbox, with one writable path (`/workspace`, the per-run directory) and thenetwork only through the runner's proxy, which allows exactly `registry.npmjs.org` and `codeload.github.com`-- (the pinned OpenVibe tarballs). Everything else is refused, including private and link-local addresses.++ (the pinned OpenVibe tarballs). Everything else is refused, including private and link-local addresses. A++ dependency whose postinstall downloads with Node's own `fetch` (mediasoup's worker, onnxruntime's CUDA package)++ does not see that proxy, so those binaries are baked into a local image instead of fetched during the run.4. **Test phase** — a fresh sandbox with **no network at all** (`--network=none` / an empty network namespace),the same workspace, no proxy socket mounted.5. The log streams to the forge while it runs; the verdict goes back with the duration and is written as the@@@ -40,8 +42,20 @@ error when the runner reports; a timed-out run is a failure.The commands, image and limit are per repository, registered by the runner from `~/.config/openvibe/git-ci.json`.The defaults are `npm ci --no-audit --no-fund` then `npm test`, the image `docker.io/library/node:22-bookworm`, and--30 minutes. `OpenVibe.Games` uses pnpm (`corepack enable --install-directory /workspace/.pnpm/bin &&--pnpm install --frozen-lockfile`, then `pnpm typecheck && pnpm lint && pnpm test`).++30 minutes. The first five repositories use three local images on top of the node image:++++- `localhost/openvibe-ci-node22:1` — node 22 + pnpm 10.6.5 (`OpenVibe.Games`), because Corepack downloads pnpm with++ Node's `fetch`, which has no proxy.++- `localhost/openvibe-ci-node22-ffmpeg:1` — the pnpm image + ffmpeg (`OpenVibe.Chat`), matching GitHub's++ `apt-packages: ffmpeg`.++- `localhost/openvibe-ci-node22-mediasoup:1` — node 22 + the mediasoup 3.25.0 prebuilt worker, kernel6 asset++ (`OpenVibe.Live`); `MEDIASOUP_WORKER_BIN` points at it, so the postinstall does not fetch from GitHub. Live's++ install also sets `ONNXRUNTIME_NODE_INSTALL=skip`: the CUDA package is not needed on this CPU-only host and++ would fetch from NuGet.++++The runner's own relay and proxy socket live in the container's `/run` tmpfs, never in the checkout: a++repository's linter must not see them. `OpenVibe.Games`' pnpm store is `node_modules/.pnpm-store` for the same++reason.## How it is contained@@@ -119,6 +133,23 @@ The service holds only the scrypt hash of the token (`deploy/ci/runner-token`, o`/etc/openvibe/git.env`). To rotate: make a new token, put the new line in the repository, deploy, restart therunner. The old token stops working when the new line is live.++## Measured (2026-11-11 UTC)++++Same commit per row; forge time is claim → verdict, GitHub time is `run_started_at` → `updated_at`. Both include++install and tests; GitHub's queue is not included.++++| repository | head | forge | GitHub |++|---|---|---|---|++| OpenVibe.Shared | `58934cc` | **1m 35s** success | 10m 53s success (the same head also had a 12m 59s failure) |++| OpenVibe.Games | `4070e5f` | **3m 46s** success | 13m 56s success |++| OpenVibe.Network | `ea86dc2` | **11m 29s** success | 22m 52s success |++| OpenVibe.Chat | `85d60a8` | **20m 13s** success | 18m 30s success |++| OpenVibe.Live | `9e4c000` | **7m 56s** success | 10m 59s success |++++Chat is the one slower forge run (20m 13s against 18m 30s): the old i7-4770K runs the 134-file suite serially, while++GitHub's runner is a newer 4-vCPU machine, and the forge run has no warm cache. The other four are faster, mostly++because there is no install cache on GitHub and no queue here.++## The service side- `migrations/0008_ci_runs.sql` — `git_ci_repos` (enabled, commands, image, limits) and `git_ci_runs` (state, log,@@@ -11,19 +11,9 @@ const path = require('node:path');const { execFile, spawnSync } = require('node:child_process');const { createProxy } = require('./proxy');const { runProcess } = require('./process');--const { podmanArgs, bwrapArgs, bwrapScope, installScript, podmanCleanup, scopeCleanup, detectEngine } = require('./sandbox');----const RELAY = `'use strict';--const net = require('node:net');--const [sock, port] = process.argv.slice(2);--const server = net.createServer((client) => {-- const upstream = net.connect(sock);-- client.pipe(upstream); upstream.pipe(client);-- client.on('error', () => upstream.destroy());-- upstream.on('error', () => client.destroy());--});--server.listen(Number(port), '127.0.0.1');--`;++const { podmanArgs, bwrapArgs, bwrapScope, installScript, podmanCleanup, scopeCleanup, detectEngine, RELAY } = require('./sandbox');++++const git = (args, cwd, timeoutMs = 180_000) => new Promise((resolve, reject) => {execFile('git', args, { cwd, timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '' } }, (err, stdout, stderr) => {@@@ -99,7 +89,6 @@ function createRunner({ cfg, api, log = console, clock = Date.now }) {fs.mkdirSync(path.join(runDir, '.home'), { recursive: true });fs.mkdirSync(path.join(runDir, '.cache', 'npm'), { recursive: true });fs.mkdirSync(path.join(runDir, '.pnpm'), { recursive: true });-- fs.writeFileSync(path.join(runDir, '.ci', 'relay.cjs'), RELAY, { mode: 0o600 });fs.writeFileSync(localLog, '', { mode: 0o600 });const limits = job.limits || { timeout_seconds: 1800, cpus: 4, memory_mb: 6144 };@@@ -16,6 +16,20 @@ const { spawnSync } = require('node:child_process');const UID = 1000;const GID = 1000;++// The loopback relay from the container's TCP proxy port to the Unix socket the runner exposes. It lives in++// /run (a tmpfs), never in the checked-out repository: a repository's linter must not see the runner's files.++const RELAY = `'use strict';++const net = require('node:net');++const [sock, port] = process.argv.slice(2);++const server = net.createServer((client) => {++ const upstream = net.connect(sock);++ client.pipe(upstream); upstream.pipe(client);++ client.on('error', () => upstream.destroy());++ upstream.on('error', () => client.destroy());++});++server.listen(Number(port), '127.0.0.1');++`;++function have(command) {const out = spawnSync(command, ['--version'], { encoding: 'utf8', timeout: 10_000 });return !out.error && out.status === 0;@@@ -52,7 +66,8 @@ const envList = (extra = {}) => ({npm_config_fund: 'false',npm_config_audit: 'false',npm_config_update_notifier: 'false',-- PNPM_HOME: '/workspace/.pnpm',++ PNPM_HOME: '/workspace/.home/.pnpm',++ npm_config_store_dir: '/workspace/node_modules/.pnpm-store',...extra,});@@@ -60,7 +75,9 @@ const envList = (extra = {}) => ({function installScript(command, { proxy = true } = {}) {return `#!/bin/shset -eu--${proxy ? `node /workspace/.ci/relay.cjs /workspace/.ci/proxy.sock 3128 &++${proxy ? `cat > /run/relay.cjs <<'OV_RELAY'++${RELAY}OV_RELAY++node /run/relay.cjs /run/ci-proxy.sock 3128 &relay=$!trap 'kill "$relay" 2>/dev/null || true' EXITexport http_proxy=http://127.0.0.1:3128 https_proxy=http://127.0.0.1:3128@@@ -81,13 +98,13 @@ function podmanArgs({ name, workspace, image, limits, proxySock = null, script }'run', '--rm', '--name', name, '--pull=never', '--network=none','--cpus', String(limits.cpus), '--memory', `${limits.memory_mb}m`, '--memory-swap', `${limits.memory_mb}m`,'--pids-limit', '2048', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges',-- '--read-only', '--tmpfs', '/tmp:rw,exec,size=2g', '--shm-size', '512m',++ '--read-only', '--tmpfs', '/tmp:rw,exec,size=2g', '--tmpfs', '/run:rw,exec,size=64m,mode=1777', '--shm-size', '512m','--userns=keep-id', '--workdir', '/workspace','-v', `${workspace}:/workspace:rw`,'--stop-timeout', '5',];for (const [key, value] of Object.entries(envList())) args.push('-e', `${key}=${value}`);-- if (proxySock) args.push('-v', `${proxySock}:/workspace/.ci/proxy.sock:rw`);++ if (proxySock) args.push('-v', `${proxySock}:/run/ci-proxy.sock:rw`);args.push(image, '/bin/sh', '-c', script);return args;}@@@ -149,9 +166,9 @@ function bwrapArgs({ workspace, limits, proxySock = null, script }) {for (const name of ['passwd', 'group', 'hosts', 'hostname', 'resolv.conf', 'nsswitch.conf']) {args.push('--ro-bind', path.join(etc, name), `/etc/${name}`);}-- args.push('--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp');++ args.push('--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp', '--perms', '1777', '--tmpfs', '/run');args.push('--bind', workspace, '/workspace');-- if (proxySock) args.push('--bind', proxySock, '/workspace/.ci/proxy.sock');++ if (proxySock) args.push('--bind', proxySock, '/run/ci-proxy.sock');args.push('--remount-ro', '/', '--chdir', '/workspace', '--', '/bin/sh', '-c', script);return { args, nodeInside };}@@@ -181,5 +198,5 @@ function scopeCleanup(unit) {}module.exports = {-- detectEngine, podmanArgs, bwrapArgs, bwrapScope, installScript, podmanCleanup, scopeCleanup, envList, UID, GID,++ detectEngine, podmanArgs, bwrapArgs, bwrapScope, installScript, podmanCleanup, scopeCleanup, envList, UID, GID, RELAY,};
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
},
},
"openvibers/openvibe.games": {
"openvibers/openvibe.games": {
"image": "localhost/openvibe-ci-node22:1",
"image": "localhost/openvibe-ci-node22:1",
- "install_command": "pnpm install --frozen-lockfile",
+ "install_command": "pnpm install --frozen-lockfile --store-dir /workspace/node_modules/.pnpm-store",
"test_command": "pnpm typecheck && pnpm lint && pnpm test",
"test_command": "pnpm typecheck && pnpm lint && pnpm test",
"timeout_seconds": 1800
"timeout_seconds": 1800
},
},
@@ -26,12 +26,14 @@
@@ -26,12 +26,14 @@
"timeout_seconds": 1800
"timeout_seconds": 1800
},
},
"openvibers/openvibe.chat": {
"openvibers/openvibe.chat": {
+ "image": "localhost/openvibe-ci-node22-ffmpeg:1",
"install_command": "npm ci --no-audit --no-fund",
"install_command": "npm ci --no-audit --no-fund",
"test_command": "npm test",
"test_command": "npm test",
"timeout_seconds": 1800
"timeout_seconds": 1800
},
},
"openvibers/openvibe.live": {
"openvibers/openvibe.live": {
- "install_command": "npm ci --no-audit --no-fund",
+ "image": "localhost/openvibe-ci-node22-mediasoup:1",
+ "install_command": "ONNXRUNTIME_NODE_INSTALL=skip npm ci --no-audit --no-fund",
"test_command": "npm test",
"test_command": "npm test",
"timeout_seconds": 1800
"timeout_seconds": 1800
}
}
@@ -64,6 +64,27 @@ EOF
@@ -64,6 +64,27 @@ EOF
printf 'FROM docker.io/library/node:22-bookworm\nRUN npm install -g [email protected]\n' \
printf 'FROM docker.io/library/node:22-bookworm\nRUN npm install -g [email protected]\n' \
| $PODMAN build --pull=never -t localhost/openvibe-ci-node22:1 -
| $PODMAN build --pull=never -t localhost/openvibe-ci-node22:1 -
fi
fi
+ # Chat's sound tests convert audio, as GitHub's ffmpeg apt step does.
+ if ! $PODMAN image exists localhost/openvibe-ci-node22-ffmpeg:1; then
+ say "building localhost/openvibe-ci-node22-ffmpeg:1 (the pnpm image + ffmpeg)"
+ printf 'FROM localhost/openvibe-ci-node22:1\nRUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/*\n' \
+ | $PODMAN build --pull=never -t localhost/openvibe-ci-node22-ffmpeg:1 -
+ fi
+ # Live's mediasoup postinstall downloads a prebuilt worker from GitHub; Node's fetch has no proxy, so the
+ # worker is baked into the image instead. kernel6 is the asset whose libstdc++ matches bookworm.
+ if ! $PODMAN image exists localhost/openvibe-ci-node22-mediasoup:1; then
+ say "building localhost/openvibe-ci-node22-mediasoup:1 (node 22 + the mediasoup worker)"
+ cat <<'DOCKER' | $PODMAN build --pull=never -t localhost/openvibe-ci-node22-mediasoup:1 -
+FROM docker.io/library/node:22-bookworm
+RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
+ && mkdir -p /opt/mediasoup \
+ && curl -fsSL https://github.com/versatica/mediasoup/releases/download/3.25.0/mediasoup-worker-3.25.0-linux-x64-kernel6.tgz -o /tmp/w.tgz \
+ && tar -xzf /tmp/w.tgz -C /opt/mediasoup \
+ && chmod 0755 /opt/mediasoup/mediasoup-worker \
+ && rm -f /tmp/w.tgz && rm -rf /var/lib/apt/lists/*
+ENV MEDIASOUP_WORKER_BIN=/opt/mediasoup/mediasoup-worker
+DOCKER
+ fi
elif command -v bwrap >/dev/null 2>&1; then
elif command -v bwrap >/dev/null 2>&1; then
say "sandbox engine: bubblewrap $(bwrap --version | awk '{print $2}') (podman is not rootless here)"
say "sandbox engine: bubblewrap $(bwrap --version | awk '{print $2}') (podman is not rootless here)"
else
else
@@ -31,7 +31,9 @@ error when the runner reports; a timed-out run is a failure.
@@ -31,7 +31,9 @@ error when the runner reports; a timed-out run is a failure.
while the mirror catches up with the webhook.
while the mirror catches up with the webhook.
3. **Install phase** — inside the sandbox, with one writable path (`/workspace`, the per-run directory) and the
3. **Install phase** — inside the sandbox, with one writable path (`/workspace`, the per-run directory) and the
network only through the runner's proxy, which allows exactly `registry.npmjs.org` and `codeload.github.com`
network only through the runner's proxy, which allows exactly `registry.npmjs.org` and `codeload.github.com`
- (the pinned OpenVibe tarballs). Everything else is refused, including private and link-local addresses.
+ (the pinned OpenVibe tarballs). Everything else is refused, including private and link-local addresses. A
+ dependency whose postinstall downloads with Node's own `fetch` (mediasoup's worker, onnxruntime's CUDA package)
+ does not see that proxy, so those binaries are baked into a local image instead of fetched during the run.
4. **Test phase** — a fresh sandbox with **no network at all** (`--network=none` / an empty network namespace),
4. **Test phase** — a fresh sandbox with **no network at all** (`--network=none` / an empty network namespace),
the same workspace, no proxy socket mounted.
the same workspace, no proxy socket mounted.
5. The log streams to the forge while it runs; the verdict goes back with the duration and is written as the
5. The log streams to the forge while it runs; the verdict goes back with the duration and is written as the
@@ -40,8 +42,20 @@ error when the runner reports; a timed-out run is a failure.
@@ -40,8 +42,20 @@ error when the runner reports; a timed-out run is a failure.
The commands, image and limit are per repository, registered by the runner from `~/.config/openvibe/git-ci.json`.
The commands, image and limit are per repository, registered by the runner from `~/.config/openvibe/git-ci.json`.
The defaults are `npm ci --no-audit --no-fund` then `npm test`, the image `docker.io/library/node:22-bookworm`, and
The defaults are `npm ci --no-audit --no-fund` then `npm test`, the image `docker.io/library/node:22-bookworm`, and
-30 minutes. `OpenVibe.Games` uses pnpm (`corepack enable --install-directory /workspace/.pnpm/bin &&
-pnpm install --frozen-lockfile`, then `pnpm typecheck && pnpm lint && pnpm test`).
+30 minutes. The first five repositories use three local images on top of the node image:
+
+- `localhost/openvibe-ci-node22:1` — node 22 + pnpm 10.6.5 (`OpenVibe.Games`), because Corepack downloads pnpm with
+ Node's `fetch`, which has no proxy.
+- `localhost/openvibe-ci-node22-ffmpeg:1` — the pnpm image + ffmpeg (`OpenVibe.Chat`), matching GitHub's
+ `apt-packages: ffmpeg`.
+- `localhost/openvibe-ci-node22-mediasoup:1` — node 22 + the mediasoup 3.25.0 prebuilt worker, kernel6 asset
+ (`OpenVibe.Live`); `MEDIASOUP_WORKER_BIN` points at it, so the postinstall does not fetch from GitHub. Live's
+ install also sets `ONNXRUNTIME_NODE_INSTALL=skip`: the CUDA package is not needed on this CPU-only host and
+ would fetch from NuGet.
+
+The runner's own relay and proxy socket live in the container's `/run` tmpfs, never in the checkout: a
+repository's linter must not see them. `OpenVibe.Games`' pnpm store is `node_modules/.pnpm-store` for the same
+reason.
## How it is contained
## How it is contained
@@ -119,6 +133,23 @@ The service holds only the scrypt hash of the token (`deploy/ci/runner-token`, o
@@ -119,6 +133,23 @@ The service holds only the scrypt hash of the token (`deploy/ci/runner-token`, o
`/etc/openvibe/git.env`). To rotate: make a new token, put the new line in the repository, deploy, restart the
`/etc/openvibe/git.env`). To rotate: make a new token, put the new line in the repository, deploy, restart the
runner. The old token stops working when the new line is live.
runner. The old token stops working when the new line is live.
+## Measured (2026-11-11 UTC)
+
+Same commit per row; forge time is claim → verdict, GitHub time is `run_started_at` → `updated_at`. Both include
+install and tests; GitHub's queue is not included.
+
+| repository | head | forge | GitHub |
+|---|---|---|---|
+| OpenVibe.Shared | `58934cc` | **1m 35s** success | 10m 53s success (the same head also had a 12m 59s failure) |
+| OpenVibe.Games | `4070e5f` | **3m 46s** success | 13m 56s success |
+| OpenVibe.Network | `ea86dc2` | **11m 29s** success | 22m 52s success |
+| OpenVibe.Chat | `85d60a8` | **20m 13s** success | 18m 30s success |
+| OpenVibe.Live | `9e4c000` | **7m 56s** success | 10m 59s success |
+
+Chat is the one slower forge run (20m 13s against 18m 30s): the old i7-4770K runs the 134-file suite serially, while
+GitHub's runner is a newer 4-vCPU machine, and the forge run has no warm cache. The other four are faster, mostly
+because there is no install cache on GitHub and no queue here.
+
## The service side
## The service side
- `migrations/0008_ci_runs.sql` — `git_ci_repos` (enabled, commands, image, limits) and `git_ci_runs` (state, log,
- `migrations/0008_ci_runs.sql` — `git_ci_repos` (enabled, commands, image, limits) and `git_ci_runs` (state, log,
@@ -11,19 +11,9 @@ const path = require('node:path');
@@ -11,19 +11,9 @@ const path = require('node:path');
const { execFile, spawnSync } = require('node:child_process');
const { execFile, spawnSync } = require('node:child_process');
const { createProxy } = require('./proxy');
const { createProxy } = require('./proxy');
const { runProcess } = require('./process');
const { runProcess } = require('./process');
-const { podmanArgs, bwrapArgs, bwrapScope, installScript, podmanCleanup, scopeCleanup, detectEngine } = require('./sandbox');
-
-const RELAY = `'use strict';
-const net = require('node:net');
-const [sock, port] = process.argv.slice(2);
-const server = net.createServer((client) => {
- const upstream = net.connect(sock);
- client.pipe(upstream); upstream.pipe(client);
- client.on('error', () => upstream.destroy());
- upstream.on('error', () => client.destroy());
-});
-server.listen(Number(port), '127.0.0.1');
-`;
+const { podmanArgs, bwrapArgs, bwrapScope, installScript, podmanCleanup, scopeCleanup, detectEngine, RELAY } = require('./sandbox');
+
+
const git = (args, cwd, timeoutMs = 180_000) => new Promise((resolve, reject) => {
const git = (args, cwd, timeoutMs = 180_000) => new Promise((resolve, reject) => {
execFile('git', args, { cwd, timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '' } }, (err, stdout, stderr) => {
execFile('git', args, { cwd, timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '' } }, (err, stdout, stderr) => {
@@ -99,7 +89,6 @@ function createRunner({ cfg, api, log = console, clock = Date.now }) {
@@ -99,7 +89,6 @@ function createRunner({ cfg, api, log = console, clock = Date.now }) {
fs.mkdirSync(path.join(runDir, '.home'), { recursive: true });
fs.mkdirSync(path.join(runDir, '.home'), { recursive: true });
fs.mkdirSync(path.join(runDir, '.cache', 'npm'), { recursive: true });
fs.mkdirSync(path.join(runDir, '.cache', 'npm'), { recursive: true });
fs.mkdirSync(path.join(runDir, '.pnpm'), { recursive: true });
fs.mkdirSync(path.join(runDir, '.pnpm'), { recursive: true });
- fs.writeFileSync(path.join(runDir, '.ci', 'relay.cjs'), RELAY, { mode: 0o600 });
fs.writeFileSync(localLog, '', { mode: 0o600 });
fs.writeFileSync(localLog, '', { mode: 0o600 });
const limits = job.limits || { timeout_seconds: 1800, cpus: 4, memory_mb: 6144 };
const limits = job.limits || { timeout_seconds: 1800, cpus: 4, memory_mb: 6144 };
@@ -16,6 +16,20 @@ const { spawnSync } = require('node:child_process');
@@ -16,6 +16,20 @@ const { spawnSync } = require('node:child_process');
const UID = 1000;
const UID = 1000;
const GID = 1000;
const GID = 1000;
+// The loopback relay from the container's TCP proxy port to the Unix socket the runner exposes. It lives in
+// /run (a tmpfs), never in the checked-out repository: a repository's linter must not see the runner's files.
+const RELAY = `'use strict';
+const net = require('node:net');
+const [sock, port] = process.argv.slice(2);
+const server = net.createServer((client) => {
+ const upstream = net.connect(sock);
+ client.pipe(upstream); upstream.pipe(client);
+ client.on('error', () => upstream.destroy());
+ upstream.on('error', () => client.destroy());
+});
+server.listen(Number(port), '127.0.0.1');
+`;
+
function have(command) {
function have(command) {
const out = spawnSync(command, ['--version'], { encoding: 'utf8', timeout: 10_000 });
const out = spawnSync(command, ['--version'], { encoding: 'utf8', timeout: 10_000 });
return !out.error && out.status === 0;
return !out.error && out.status === 0;
@@ -52,7 +66,8 @@ const envList = (extra = {}) => ({
@@ -52,7 +66,8 @@ const envList = (extra = {}) => ({
npm_config_fund: 'false',
npm_config_fund: 'false',
npm_config_audit: 'false',
npm_config_audit: 'false',
npm_config_update_notifier: 'false',
npm_config_update_notifier: 'false',
- PNPM_HOME: '/workspace/.pnpm',
+ PNPM_HOME: '/workspace/.home/.pnpm',
+ npm_config_store_dir: '/workspace/node_modules/.pnpm-store',
...extra,
...extra,
});
});
@@ -60,7 +75,9 @@ const envList = (extra = {}) => ({
@@ -60,7 +75,9 @@ const envList = (extra = {}) => ({
function installScript(command, { proxy = true } = {}) {
function installScript(command, { proxy = true } = {}) {
return `#!/bin/sh
return `#!/bin/sh
set -eu
set -eu
-${proxy ? `node /workspace/.ci/relay.cjs /workspace/.ci/proxy.sock 3128 &
+${proxy ? `cat > /run/relay.cjs <<'OV_RELAY'
+${RELAY}OV_RELAY
+node /run/relay.cjs /run/ci-proxy.sock 3128 &
relay=$!
relay=$!
trap 'kill "$relay" 2>/dev/null || true' EXIT
trap 'kill "$relay" 2>/dev/null || true' EXIT
export http_proxy=http://127.0.0.1:3128 https_proxy=http://127.0.0.1:3128
export http_proxy=http://127.0.0.1:3128 https_proxy=http://127.0.0.1:3128
@@ -81,13 +98,13 @@ function podmanArgs({ name, workspace, image, limits, proxySock = null, script }
@@ -81,13 +98,13 @@ function podmanArgs({ name, workspace, image, limits, proxySock = null, script }
'run', '--rm', '--name', name, '--pull=never', '--network=none',
'run', '--rm', '--name', name, '--pull=never', '--network=none',
'--cpus', String(limits.cpus), '--memory', `${limits.memory_mb}m`, '--memory-swap', `${limits.memory_mb}m`,
'--cpus', String(limits.cpus), '--memory', `${limits.memory_mb}m`, '--memory-swap', `${limits.memory_mb}m`,
'--pids-limit', '2048', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges',
'--pids-limit', '2048', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges',
- '--read-only', '--tmpfs', '/tmp:rw,exec,size=2g', '--shm-size', '512m',
+ '--read-only', '--tmpfs', '/tmp:rw,exec,size=2g', '--tmpfs', '/run:rw,exec,size=64m,mode=1777', '--shm-size', '512m',
'--userns=keep-id', '--workdir', '/workspace',
'--userns=keep-id', '--workdir', '/workspace',
'-v', `${workspace}:/workspace:rw`,
'-v', `${workspace}:/workspace:rw`,
'--stop-timeout', '5',
'--stop-timeout', '5',
];
];
for (const [key, value] of Object.entries(envList())) args.push('-e', `${key}=${value}`);
for (const [key, value] of Object.entries(envList())) args.push('-e', `${key}=${value}`);
- if (proxySock) args.push('-v', `${proxySock}:/workspace/.ci/proxy.sock:rw`);
+ if (proxySock) args.push('-v', `${proxySock}:/run/ci-proxy.sock:rw`);
args.push(image, '/bin/sh', '-c', script);
args.push(image, '/bin/sh', '-c', script);
return args;
return args;
}
}
@@ -149,9 +166,9 @@ function bwrapArgs({ workspace, limits, proxySock = null, script }) {
@@ -149,9 +166,9 @@ function bwrapArgs({ workspace, limits, proxySock = null, script }) {
for (const name of ['passwd', 'group', 'hosts', 'hostname', 'resolv.conf', 'nsswitch.conf']) {
for (const name of ['passwd', 'group', 'hosts', 'hostname', 'resolv.conf', 'nsswitch.conf']) {
args.push('--ro-bind', path.join(etc, name), `/etc/${name}`);
args.push('--ro-bind', path.join(etc, name), `/etc/${name}`);
}
}
- args.push('--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp');
+ args.push('--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp', '--perms', '1777', '--tmpfs', '/run');
args.push('--bind', workspace, '/workspace');
args.push('--bind', workspace, '/workspace');
- if (proxySock) args.push('--bind', proxySock, '/workspace/.ci/proxy.sock');
+ if (proxySock) args.push('--bind', proxySock, '/run/ci-proxy.sock');
args.push('--remount-ro', '/', '--chdir', '/workspace', '--', '/bin/sh', '-c', script);
args.push('--remount-ro', '/', '--chdir', '/workspace', '--', '/bin/sh', '-c', script);
return { args, nodeInside };
return { args, nodeInside };
}
}
@@ -181,5 +198,5 @@ function scopeCleanup(unit) {
@@ -181,5 +198,5 @@ function scopeCleanup(unit) {
}
}
module.exports = {
module.exports = {
- detectEngine, podmanArgs, bwrapArgs, bwrapScope, installScript, podmanCleanup, scopeCleanup, envList, UID, GID,
+ detectEngine, podmanArgs, bwrapArgs, bwrapScope, installScript, podmanCleanup, scopeCleanup, envList, UID, GID, RELAY,
};
};