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
13 changes: 9 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ jobs:
- name: Block-root package delivery
run: tests/block_root_package_delivery.sh

- name: KVM guest resolver handoff
run: tests/kvm_guest_dns.sh

- name: Sandbox-runner liveness checks
run: tests/sandbox_runner_healthcheck.sh

Expand Down Expand Up @@ -125,8 +128,12 @@ jobs:
run: bun run test

code-package-tests:
name: Code Package Tests
name: Code Package Tests (Node ${{ matrix.node-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: ['20.11.0', '22.21.0', '24.16.0']
defaults:
run:
working-directory: packages/code
Expand All @@ -135,9 +142,7 @@ jobs:

- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
# The suite fails 47 worker tests on Node 22 despite the package's
# ">=20.11" engines range, so CI pins the version it is green on.
node-version: 24.16.0
node-version: ${{ matrix.node-version }}
cache: npm
cache-dependency-path: packages/code/package-lock.json

Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,23 @@ virtio-fs mount. The first image build takes longer because it compiles the
language runtimes, but package-heavy workloads do not accumulate host file
descriptors in the launcher.

KVM guests use the runner container's `/etc/resolv.conf`, including Docker's
embedded resolver or Kubernetes nameservers and search domains. The launcher
preserves service hostnames instead of pinning their startup IP addresses.
Both baked and directory rootfs images contain a resolver symlink whose target
is populated by a guest wrapper in private `/run` runtime storage before any
`LAUNCHER_EXEC` executable starts; the
read-only root disk does not need modification at boot. Rebuild the runner
image to pick up this layout change. A missing resolver handoff fails startup
rather than leaving the guest with an unrelated public DNS server.

To validate a deployment, execute code that creates a file in `/mnt/data`,
confirm the response includes its file reference, and download it. Recreate the
egress gateway with a different container IP while leaving the runner alive,
then repeat after DNS caches expire. The file must still upload and download;
`artifact_delivery` must not report a failure. `tests/kvm_guest_dns.sh` checks
the resolver handoff and rootfs assembly without requiring KVM.

Setting `KVM_ENABLED=false` still selects the directory-root target and the
host package mount automatically for direct NsJail development.

Expand Down
7 changes: 5 additions & 2 deletions api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ RUN rm -f /usr/bin/nsenter /usr/bin/unshare /usr/bin/chroot /usr/sbin/chroot \
2>/dev/null || true

COPY api/src/entrypoint.sh ./entrypoint.sh
COPY api/src/guest-dns.sh ./guest-dns.sh
COPY api/src/hosted-app-launcher.sh /usr/local/bin/codeapi-hosted-app-launcher
RUN chmod +x ./entrypoint.sh /usr/local/bin/codeapi-hosted-app-launcher

Expand Down Expand Up @@ -266,7 +267,8 @@ COPY --from=sandbox-build / /sandbox-rootfs/
COPY --from=package-builder /pkgs /sandbox-rootfs/pkgs
COPY docker/build-rootfs-image.sh /usr/local/bin/build-rootfs-image.sh

RUN sed -i '/^cgroup_mem_swap_max/d' /sandbox-rootfs/sandbox_api/config/sandbox.cfg
RUN sed -i '/^cgroup_mem_swap_max/d' /sandbox-rootfs/sandbox_api/config/sandbox.cfg \
&& bash /sandbox-rootfs/sandbox_api/guest-dns.sh --prepare-rootfs /sandbox-rootfs
RUN chmod +x /usr/local/bin/build-rootfs-image.sh \
&& /usr/local/bin/build-rootfs-image.sh /sandbox-rootfs /sandbox-rootfs.img

Expand All @@ -284,7 +286,8 @@ FROM sandbox-runner-base AS sandbox-runner

COPY --from=sandbox-build / /sandbox-rootfs/

RUN sed -i '/^cgroup_mem_swap_max/d' /sandbox-rootfs/sandbox_api/config/sandbox.cfg
RUN sed -i '/^cgroup_mem_swap_max/d' /sandbox-rootfs/sandbox_api/config/sandbox.cfg \
&& bash /sandbox-rootfs/sandbox_api/guest-dns.sh --prepare-rootfs /sandbox-rootfs

RUN mkdir -p /host-packages

Expand Down
12 changes: 12 additions & 0 deletions api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,15 @@ curl -s http://localhost:2000/api/v2/execute \
-H 'Content-Type: application/json' \
-d '{"language":"python","version":"3.14.4","files":[{"content":"print(42)"}]}' | jq
```

### Requested runtime caps

`POST /api/v2/execute` treats `run_timeout` as an upper bound in milliseconds.
A request above the effective runtime limit is clamped to that limit, including
language and package overrides. Smaller caps are preserved, and omission uses
the runtime default. Compile, CPU, and memory constraints retain their existing
validation behavior.

Roll out this sandbox behavior before enabling timeout forwarding in the
service's plain `/exec` handler. Older sandboxes reject caps above their local
runtime limit; older services remain compatible with updated sandboxes.
74 changes: 74 additions & 0 deletions api/src/api/v2-timeout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { afterAll, afterEach, beforeAll, expect, test } from 'bun:test';
import express from 'express';
import { mkdtemp, rm, writeFile } from 'fs/promises';
import type { Server } from 'http';
import { tmpdir } from 'os';
import { join } from 'path';
import { config } from '../config';
import { Job } from '../job';
import { loadPackage } from '../runtime';
import router from './v2';

let server: Server;
let url: string;
let directory: string;
const language = 'runtime-timeout-cap-test';
const originalPrime = Job.prototype.prime;
const originalExecute = Job.prototype.execute;
const originalCleanup = Job.prototype.cleanup;
const requireManifest = config.require_execution_manifest;
const observed: number[] = [];

beforeAll(async () => {
directory = await mkdtemp(join(tmpdir(), 'runtime-timeout-'));
await writeFile(join(directory, 'pkg-info.json'), JSON.stringify({
language, version: '1.0.0', aliases: [],
limit_overrides: { run_timeout: 15000, compile_timeout: 5000 },
}));
loadPackage(directory);
const app = express();
app.use(router);
await new Promise<void>((resolve) => { server = app.listen(0, '127.0.0.1', () => resolve()); });
const address = server.address();
url = `http://127.0.0.1:${typeof address === 'object' && address ? address.port : 0}/execute`;
});

afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
await rm(directory, { recursive: true, force: true });
});
afterEach(() => {
Job.prototype.prime = originalPrime;
Job.prototype.execute = originalExecute;
Job.prototype.cleanup = originalCleanup;
config.require_execution_manifest = requireManifest;
observed.length = 0;
});

test('caps execution at the effective language runtime limit without rejecting larger caller caps', async () => {
config.require_execution_manifest = false;
Job.prototype.prime = async function () { observed.push(this.timeouts.run); };
Job.prototype.execute = async function () { return {} as Awaited<ReturnType<Job['execute']>>; };
Job.prototype.cleanup = async function () {};
for (const [input, expected] of [[25000, 15000], [15000, 15000], [1000, 1000], [null, 15000], [undefined, 15000]] as const) {
const response = await fetch(url, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ language, version: '1.0.0', run_timeout: input, files: [{ name: 'main.txt', content: 'test' }] }),
});
expect(response.status, await response.text()).toBe(200);
expect(observed[observed.length - 1]).toBe(expected);
}
});

test('invalid runtime types and compile limit violations still fail before priming', async () => {
config.require_execution_manifest = false;
Job.prototype.prime = async function () { observed.push(this.timeouts.run); };
for (const limits of [{ run_timeout: '1000' }, { run_timeout: -1 }, { compile_timeout: 6000 }]) {
const response = await fetch(url, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ language, version: '1.0.0', ...limits, files: [{ name: 'main.txt', content: 'test' }] }),
});
expect(response.status).toBe(400);
}
expect(observed).toEqual([]);
});
11 changes: 8 additions & 3 deletions api/src/api/v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ function getJob(
const {
session_id, language, version, args, stdin, files,
compile_memory_limit, run_memory_limit,
run_timeout, compile_timeout,
compile_timeout,
run_cpu_time, compile_cpu_time,
env_vars,
} = body;
Expand Down Expand Up @@ -288,7 +288,12 @@ function getJob(
throw { message: 'files must include at least one runnable source file' };
}

validateConstraints(body, rt);
// A runtime timeout is a cap, not a request to exceed the runtime's own
// limit. Resolve it here, where language/package overrides are available.
const runTimeout = typeof body.run_timeout === 'number' && rt.timeouts.run > 0
? Math.min(body.run_timeout, rt.timeouts.run)
: body.run_timeout;
validateConstraints({ ...body, run_timeout: runTimeout }, rt);

/* Session mode is per-request opt-in: only run in the persistent workspace
* when THIS request carried a valid X-Runtime-Session-Id. A headerless or
Expand Down Expand Up @@ -329,7 +334,7 @@ function getJob(
stdin: stdin ?? '',
files,
timeouts: {
run: run_timeout ?? rt.timeouts.run,
run: runTimeout ?? rt.timeouts.run,
compile: compile_timeout ?? rt.timeouts.compile,
},
cpu_times: {
Expand Down
49 changes: 49 additions & 0 deletions api/src/guest-dns.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#!/bin/bash
# The guest root may be read-only. Bake the link, populate its target only
# after /run is mounted, and leave direct NsJail/Lambda resolvers untouched.

prepare_guest_dns() {
local root="$1"
mkdir -p "$root/run"
rm -f "$root/etc/resolv.conf"
ln -s ../run/codeapi-resolver/resolv.conf "$root/etc/resolv.conf"
}

configure_guest_dns() {
local root="${1:-}"
local target="$root/run/codeapi-resolver"
if [ ! -L "$root/etc/resolv.conf" ] || \
[ "$(readlink "$root/etc/resolv.conf")" != '../run/codeapi-resolver/resolv.conf' ]; then
return 0
fi
if ! printf '%s\n' "${SANDBOX_RESOLV_CONF:-}" | grep -Eq '^[[:space:]]*nameserver[[:space:]]+[^[:space:]#]'; then
echo 'ERROR: KVM guest requires resolver configuration from launcher-entrypoint.sh' >&2
return 1
fi
# A fresh, root-owned directory prevents a sandbox UID from replacing DNS
# configuration in the runtime mount. Never reuse a pre-existing entry.
(umask 077; mkdir "$target") || return 1
printf '%s\n' "$SANDBOX_RESOLV_CONF" > "$target/resolv.conf" || return 1
chmod 600 "$target/resolv.conf" || return 1
unset SANDBOX_RESOLV_CONF
}

run_guest_command() {
local root="$1"
shift
# This runs for every LAUNCHER_EXEC, before the selected executable. Keep
# DNS separate from /tmp, which the normal API entrypoint mounts later.
mount -t tmpfs -o size=1m,mode=0755 tmpfs "$root/run" || return 1
configure_guest_dns "$root" || return 1
exec -- "$@"
}

if [ "${BASH_SOURCE[0]}" = "$0" ]; then
set -e
case "${1:-}" in
--prepare-rootfs) prepare_guest_dns "${2:?rootfs path required}" ;;
--configure) configure_guest_dns "${2:-}" ;;
--exec) run_guest_command "" "${2:?guest executable required}" ;;
*) echo 'usage: guest-dns.sh --prepare-rootfs ROOTFS | --configure [ROOTFS] | --exec EXECUTABLE' >&2; exit 2 ;;
esac
fi
7 changes: 5 additions & 2 deletions docker/Dockerfile.worker-sandbox
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ RUN bun install --frozen-lockfile --production
COPY --from=sandbox-builder /app/.build ./.build
COPY api/config ./config
COPY api/src/entrypoint.sh ./entrypoint.sh
COPY api/src/guest-dns.sh ./guest-dns.sh
RUN chmod +x ./entrypoint.sh

RUN mkdir -p /pkgs /tmp/sandbox
Expand Down Expand Up @@ -200,7 +201,8 @@ COPY --from=sandbox-rootfs / /sandbox-rootfs/
COPY --from=package-builder /pkgs /sandbox-rootfs/pkgs
COPY docker/build-rootfs-image.sh /usr/local/bin/build-rootfs-image.sh

RUN sed -i '/^cgroup_mem_swap_max/d' /sandbox-rootfs/sandbox_api/config/sandbox.cfg
RUN sed -i '/^cgroup_mem_swap_max/d' /sandbox-rootfs/sandbox_api/config/sandbox.cfg \
&& bash /sandbox-rootfs/sandbox_api/guest-dns.sh --prepare-rootfs /sandbox-rootfs
RUN chmod +x /usr/local/bin/build-rootfs-image.sh \
&& /usr/local/bin/build-rootfs-image.sh /sandbox-rootfs /sandbox-rootfs.img

Expand Down Expand Up @@ -236,7 +238,7 @@ ENV PATH="/root/.bun/bin:${PATH}"
COPY --from=launcher-builder /launcher/target/release/sandbox-launcher /usr/local/bin/launcher
COPY --from=nsjail-builder /usr/local/bin/sandbox-rootfs-setup /sandbox-rootfs-setup

# --- Launcher entrypoint (DNS resolution + socat relay before VM boot) ---
# --- Launcher entrypoint (resolver configuration before VM boot) ---
COPY launcher/entrypoint.sh /usr/local/bin/launcher-entrypoint.sh
COPY docker/start-direct-sandbox.sh /usr/local/bin/start-direct-sandbox.sh
RUN chmod +x /usr/local/bin/launcher-entrypoint.sh /usr/local/bin/start-direct-sandbox.sh
Expand Down Expand Up @@ -268,6 +270,7 @@ FROM worker-sandbox-base AS worker-sandbox-legacy
COPY --from=sandbox-rootfs / /sandbox-rootfs/

RUN sed -i '/^cgroup_mem_swap_max/d' /sandbox-rootfs/sandbox_api/config/sandbox.cfg \
&& bash /sandbox-rootfs/sandbox_api/guest-dns.sh --prepare-rootfs /sandbox-rootfs \
&& mkdir -p /host-packages

# KVM production default. The package tree is part of the read-only block root,
Expand Down
4 changes: 3 additions & 1 deletion launcher/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ RUN rm -f /usr/bin/nsenter /usr/bin/unshare /usr/bin/chroot /usr/sbin/chroot \
2>/dev/null || true

COPY api/src/entrypoint.sh ./entrypoint.sh
COPY api/src/guest-dns.sh ./guest-dns.sh
RUN chmod +x ./entrypoint.sh

# ============================================================================
Expand Down Expand Up @@ -132,7 +133,8 @@ COPY --from=nsjail-builder /usr/local/bin/sandbox-rootfs-setup /sandbox-rootfs-s

COPY --from=sandbox-build / /sandbox-rootfs/

RUN sed -i '/^cgroup_mem_swap_max/d' /sandbox-rootfs/sandbox_api/config/sandbox.cfg
RUN sed -i '/^cgroup_mem_swap_max/d' /sandbox-rootfs/sandbox_api/config/sandbox.cfg \
&& bash /sandbox-rootfs/sandbox_api/guest-dns.sh --prepare-rootfs /sandbox-rootfs

RUN mkdir -p /host-packages

Expand Down
62 changes: 9 additions & 53 deletions launcher/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -1,59 +1,15 @@
#!/bin/bash
set -e

# Resolve Docker Compose service names to IPs before entering the microVM.
# libkrun's TSI networking doesn't have access to Docker's embedded DNS (127.0.0.11),
# so DNS-based service discovery won't work inside the guest.

resolve_url() {
local var_name="$1"
local url="${!var_name}"
[ -z "$url" ] && return

local proto="${url%%://*}"
local rest="${url#*://}"
local host_port="${rest%%/*}"
local path="/${rest#*/}"
[ "$rest" = "$host_port" ] && path=""
local host="${host_port%%:*}"
local port="${host_port#*:}"
[ "$host" = "$port" ] && port=""

# Skip if already an IP
echo "$host" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' && return

local ip
ip=$(getent hosts "$host" 2>/dev/null | awk '{print $1}' | head -1)
if [ -n "$ip" ]; then
local new_url="${proto}://${ip}"
[ -n "$port" ] && new_url="${new_url}:${port}"
new_url="${new_url}${path}"
export "$var_name"="$new_url"
echo "[entrypoint] ${var_name}: ${host} -> ${ip}"
fi
}

resolve_host_port() {
local var_name="$1"
local val="${!var_name}"
[ -z "$val" ] && return

local host="${val%%:*}"
local port="${val#*:}"

echo "$host" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' && return

local ip
ip=$(getent hosts "$host" 2>/dev/null | awk '{print $1}' | head -1)
if [ -n "$ip" ]; then
export "$var_name"="${ip}:${port}"
echo "[entrypoint] ${var_name}: ${host} -> ${ip}"
fi
}

resolve_url EGRESS_GATEWAY_URL
resolve_url FILE_SERVER_URL
resolve_host_port SANDBOX_FORWARD_TARGET
# TSI opens guest sockets in this container's network namespace. Keep service
# names intact so new connections can resolve replacements after a restart.
# Forward the resolver and search domains supplied by Docker or Kubernetes,
# rather than pinning endpoint IPs or baking a deployment-specific nameserver.
export SANDBOX_RESOLV_CONF="$(cat /etc/resolv.conf)"
if ! printf '%s\n' "$SANDBOX_RESOLV_CONF" | grep -Eq '^[[:space:]]*nameserver[[:space:]]+[^[:space:]#]'; then
echo 'ERROR: runner /etc/resolv.conf has no nameserver' >&2
exit 1
fi

if [ "${LAUNCHER_FILTER_VSOCK_ENOTCONN:-true}" = "true" ]; then
# libkrun can emit this benign TSI/vsock teardown line after the guest has
Expand Down
Loading