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
34 changes: 34 additions & 0 deletions .github/workflows/test-gvisor-compat.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,40 @@ permissions:
contents: read

jobs:
bounded-query-isolation:
name: Bounded-query gVisor isolation
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install gVisor
run: |
set -euo pipefail
ARCH=$(uname -m)
URL="https://storage.googleapis.com/gvisor/releases/release/latest/${ARCH}"
wget -q "${URL}/runsc" "${URL}/containerd-shim-runsc-v1"
chmod +x runsc containerd-shim-runsc-v1
sudo mv runsc containerd-shim-runsc-v1 /usr/local/bin/
sudo mkdir -p /etc/docker
printf '{"runtimes":{"runsc":{"path":"/usr/local/bin/runsc"}}}\n' |
sudo tee /etc/docker/daemon.json
sudo systemctl restart docker
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "24"
package-manager-cache: false
- name: Exercise bounded-query isolation under runsc
env:
AWF_BOUNDED_QUERY_TEST_RUNTIME: gvisor
run: |
npm ci
npm run build
npm run test:integration -- --runInBand bounded-query-isolation.test.ts

install-gvisor:
name: Install gVisor
runs-on: ubuntu-latest
Expand Down
4 changes: 4 additions & 0 deletions containers/bounded-query/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ RUN chmod -R a-w /opt/awf \
&& node --check /opt/awf/broker/docker-client.js \
&& node --check /opt/awf/broker/docker-query-runner.js \
&& node --check /opt/awf/broker/gvisor-query-runner.js \
&& node --check /opt/awf/broker/sbx-client.js \
&& node --check /opt/awf/broker/sbx-capability-probe.js \
&& node --check /opt/awf/broker/sbx-query-runner-spec.js \
&& node --check /opt/awf/broker/sbx-query-runner.js \
&& node --check /opt/awf/broker/healthcheck.js

# Fixed broker-only mount points.
Expand Down
22 changes: 21 additions & 1 deletion containers/bounded-query/broker/broker.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,22 @@ function createBroker(params) {
const runner = params.runner;
const clock = params.clock || createRealClock();
const ledger = params.ledger || createLedger(seedMap);
const telemetry = params.telemetry || { emit() {} };

let invocationsUsed = 0;
let tail = Promise.resolve();
let accepting = true;

function emitQueryTelemetry(category) {
telemetry.emit({
primaryBackend: config.primaryBackend,
queryBackend: config.queryBackend,
lifecycleClass: 'query',
capabilityState: 'supported',
category,
});
}

/**
* Executes one request and reports its canonical result through
* `respond` (called exactly once). The invocations run only through
Expand All @@ -80,6 +91,7 @@ function createBroker(params) {
const validation = validateBoundedQueryRequest(request);
if (!validation.valid) {
audit.failure(invocationId, 'invalid-request', validation.errors.join('; '));
emitQueryTelemetry('invalid-request');
safeRespond(CANONICAL_ERROR_JSON);
return;
}
Expand All @@ -89,6 +101,7 @@ function createBroker(params) {
const seed = seedMap.get(repoKey);
if (!seed) {
audit.failure(invocationId, 'repo-not-allowed', privateRepo);
emitQueryTelemetry('repo-not-allowed');
safeRespond(CANONICAL_ERROR_JSON);
return;
}
Expand All @@ -100,6 +113,7 @@ function createBroker(params) {
const charge = queryBitsForSchema(schema);
if (!ledger.tryDebit(repoKey, charge)) {
audit.failure(invocationId, 'bit-budget-exhausted', `repo=${privateRepo} charge=${charge}`);
emitQueryTelemetry('bit-budget-exhausted');
safeRespond(CANONICAL_ERROR_JSON);
return;
}
Expand Down Expand Up @@ -175,6 +189,7 @@ function createBroker(params) {
// configured bucket — pathological infrastructure latency. Never emit a
// successful result at unbucketed timing.
audit.failure(invocationId, 'timing-bucket-overflow', failureReason ? failureReason.join(':') : undefined);
emitQueryTelemetry('timing-bucket-overflow');
safeRespond(CANONICAL_ERROR_JSON);
} else if (canonicalResult !== undefined) {
audit.invocation({
Expand All @@ -184,9 +199,12 @@ function createBroker(params) {
bits: charge,
bucketMs,
});
emitQueryTelemetry('success');
safeRespond(canonicalOkJson(canonicalResult));
} else {
audit.failure(invocationId, failureReason ? failureReason[0] : 'unknown', failureReason ? failureReason[1] : undefined);
const category = failureReason ? failureReason[0] : 'unknown';
audit.failure(invocationId, category, failureReason ? failureReason[1] : undefined);
emitQueryTelemetry(category);
safeRespond(CANONICAL_ERROR_JSON);
}

Expand Down Expand Up @@ -238,13 +256,15 @@ function createBroker(params) {
// against it.
if (invocationsUsed >= config.maxInvocations) {
audit.failure('budget', 'invocation-count-exhausted', `max=${config.maxInvocations}`);
emitQueryTelemetry('invocation-count-exhausted');
safeRespond(CANONICAL_ERROR_JSON);
return Promise.resolve();
}
invocationsUsed += 1;

const queued = tail.then(() => execute(request, safeRespond)).catch((error) => {
audit.failure('queue', 'unexpected-error', error && error.message);
emitQueryTelemetry('unexpected-error');
safeRespond(CANONICAL_ERROR_JSON);
});
tail = queued.then(
Expand Down
17 changes: 15 additions & 2 deletions containers/bounded-query/broker/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,16 +94,25 @@ function loadConfig() {
}

const queryBackend = requireEnv('AWF_BOUNDED_QUERY_BACKEND');
if (queryBackend !== 'docker' && queryBackend !== 'gvisor') {
if (queryBackend !== 'docker' && queryBackend !== 'gvisor' && queryBackend !== 'sbx') {
throw new Error(`Unsupported AWF_BOUNDED_QUERY_BACKEND: ${queryBackend}`);
}
const primaryBackend = requireEnv('AWF_BOUNDED_QUERY_PRIMARY_BACKEND');
if (primaryBackend !== 'docker' && primaryBackend !== 'gvisor' && primaryBackend !== 'sbx') {
throw new Error(`Unsupported AWF_BOUNDED_QUERY_PRIMARY_BACKEND: ${primaryBackend}`);
}

const tcpPortRaw = process.env.AWF_BOUNDED_QUERY_TCP_PORT;
const tcpPort = tcpPortRaw === undefined ? undefined : parsePositiveInt('AWF_BOUNDED_QUERY_TCP_PORT');
if (tcpPort !== undefined && tcpPort > 65535) {
throw new Error('AWF_BOUNDED_QUERY_TCP_PORT must be a valid TCP port');
}

const hostWorkDir = requireEnv('AWF_BOUNDED_QUERY_HOST_WORK_DIR');
const sbxWorkDir = queryBackend === 'sbx'
? requireEnv('AWF_BOUNDED_QUERY_SBX_WORK_DIR')
: undefined;

return {
seedsDir: SEEDS_DIR,
workDir: WORK_DIR,
Expand All @@ -121,8 +130,12 @@ function loadConfig() {
queryImage: requireEnv('AWF_BOUNDED_QUERY_IMAGE'),
// The daemon resolves query bind-mount sources in *its* filesystem view,
// which is not necessarily the broker's (ARC/DinD split filesystems).
hostWorkDir: requireEnv('AWF_BOUNDED_QUERY_HOST_WORK_DIR'),
hostWorkDir,
// sbx and Docker daemons can have different filesystem namespaces (ARC/DinD).
// Never reuse the Docker-daemon-visible path for sbx mounts.
sbxWorkDir,
queryBackend,
primaryBackend,
timeoutSeconds: parseTimeoutSeconds(),
maxInvocations: parsePositiveInt('AWF_BOUNDED_QUERY_MAX_INVOCATIONS', 32),
memoryLimit,
Expand Down
4 changes: 4 additions & 0 deletions containers/bounded-query/broker/query-runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

const { DockerQueryRunner } = require('./docker-query-runner');
const { GvisorQueryRunner } = require('./gvisor-query-runner');
const { SbxQueryRunner } = require('./sbx-query-runner');
const {
QUERY_MAX_FILE_BYTES,
QUERY_WORKSPACE_TMPFS_BYTES,
Expand Down Expand Up @@ -38,6 +39,9 @@ function createQueryRunner(config, deps = {}) {
if (config.queryBackend === 'gvisor') {
return new GvisorQueryRunner(config, deps);
}
if (config.queryBackend === 'sbx') {
return new SbxQueryRunner(config, deps);
}
throw new Error(`Unsupported bounded-query backend: ${config.queryBackend}`);
}

Expand Down
56 changes: 56 additions & 0 deletions containers/bounded-query/broker/runtime-telemetry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
'use strict';

const fs = require('fs');
const path = require('path');

const PRIMARY_BACKENDS = new Set(['docker', 'gvisor', 'sbx']);
const QUERY_BACKENDS = new Set(['docker', 'gvisor', 'sbx']);
const LIFECYCLE_CLASSES = new Set(['preflight', 'startup', 'query', 'cleanup']);
const CAPABILITY_STATES = new Set(['supported', 'unavailable', 'blocked']);
const CATEGORY_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;

function assertTelemetryValue(allowed, value, field) {
if (!allowed.has(value)) throw new Error(`Invalid bounded-query telemetry ${field}`);
}

function buildRuntimeTelemetryRecord(event) {
assertTelemetryValue(PRIMARY_BACKENDS, event.primaryBackend, 'primaryBackend');
assertTelemetryValue(QUERY_BACKENDS, event.queryBackend, 'queryBackend');
assertTelemetryValue(LIFECYCLE_CLASSES, event.lifecycleClass, 'lifecycleClass');
assertTelemetryValue(CAPABILITY_STATES, event.capabilityState, 'capabilityState');
if (typeof event.category !== 'string' || !CATEGORY_PATTERN.test(event.category)) {
throw new Error('Invalid bounded-query telemetry category');
}
return Object.freeze({
primaryBackend: event.primaryBackend,
queryBackend: event.queryBackend,
lifecycleClass: event.lifecycleClass,
capabilityState: event.capabilityState,
category: event.category,
});
}

function createRuntimeTelemetry(auditDir) {
fs.mkdirSync(auditDir, { recursive: true, mode: 0o700 });
const telemetryPath = path.join(auditDir, 'runtime-telemetry.jsonl');
let fd = fs.openSync(telemetryPath, 'a', 0o600);
return {
emit(event) {
const record = buildRuntimeTelemetryRecord(event);
if (fd === undefined) return;
try {
fs.writeSync(fd, `${JSON.stringify(record)}\n`);
} catch {
process.stderr.write('[bounded-query] runtime telemetry unavailable\n');
try {
fs.closeSync(fd);
} catch {
// The generic telemetry failure above is the only safe diagnostic.
}
fd = undefined;
}
},
};
}

module.exports = { buildRuntimeTelemetryRecord, createRuntimeTelemetry };
103 changes: 103 additions & 0 deletions containers/bounded-query/broker/sbx-capability-probe.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
#!/usr/bin/env node
'use strict';

const defaultSbxClient = require('./sbx-client');

const AUDITED_SBX_VERSION = '0.37.1';
const REQUIRED_CREATE_FLAGS = Object.freeze([
'--cpus',
'--memory',
'--name',
'--template',
]);
const REQUIRED_EXEC_FLAGS = Object.freeze([
'--user',
'--workdir',
]);

/**
* Capabilities that sbx must expose before AWF can safely launch a query VM.
*
* sbx v0.37.1 lacks the final five controls. Local or kit network rules are
* insufficient because organization governance can replace them.
*/
const REQUIRED_HARD_ISOLATION_FLAGS = Object.freeze([
'--network=none',
'--pids-limit',
'--disk-limit',
'--ulimit-fsize',
'--mount-target',
]);

function includesFlag(help, flag) {
const escaped = flag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`(^|[\\s,])${escaped}(?=([=\\s,]|$))`, 'm').test(help);
}

async function inspectHelp(sbx, command) {
const result = await sbx.runSbx([command, '--help'], 10_000);
return result.exitCode === 0 ? result.stdout : '';
}

async function probeSbxCapabilities(sbx = defaultSbxClient) {
const versionResult = await sbx.runSbx(['version'], 10_000);
const daemonResult = await sbx.runSbx(['ls'], 10_000);
const createHelp = await inspectHelp(sbx, 'create');
const execHelp = await inspectHelp(sbx, 'exec');
Comment thread
lpcox marked this conversation as resolved.
const versionMatch = /\bv?(\d+\.\d+\.\d+)\b/.exec(versionResult.stdout);
const version = versionMatch ? versionMatch[1] : undefined;
const missing = [];

// AWF has not published the immutable Python-only sbx template/bootstrap
// because current sbx cannot yet enforce the controls below.
missing.push('pinned AWF Python query template and bootstrap');

if (versionResult.exitCode !== 0 || !version || daemonResult.exitCode !== 0) {
missing.push('authenticated sbx CLI/daemon');
}
if (version && version !== AUDITED_SBX_VERSION) {
missing.push(`audited sbx version ${AUDITED_SBX_VERSION} (found ${version})`);
}
for (const flag of REQUIRED_CREATE_FLAGS) {
if (!includesFlag(createHelp, flag)) missing.push(`sbx create ${flag}`);
}
for (const flag of REQUIRED_EXEC_FLAGS) {
if (!includesFlag(execHelp, flag)) missing.push(`sbx exec ${flag}`);
}
for (const flag of REQUIRED_HARD_ISOLATION_FLAGS) {
if (!includesFlag(createHelp, flag)) missing.push(`sbx create ${flag}`);
}

return Object.freeze({
supported: missing.length === 0,
version,
auditedVersion: AUDITED_SBX_VERSION,
missing: Object.freeze(missing),
});
}

async function main() {
const report = await probeSbxCapabilities();
process.stdout.write(`${JSON.stringify(report)}\n`);
process.exitCode = report.supported ? 0 : 1;
}

if (require.main === module) {
main().catch((error) => {
process.stdout.write(`${JSON.stringify({
supported: false,
auditedVersion: AUDITED_SBX_VERSION,
missing: ['capability probe failed'],
error: error.message,
})}\n`);
process.exitCode = 1;
});
}

module.exports = {
AUDITED_SBX_VERSION,
REQUIRED_CREATE_FLAGS,
REQUIRED_EXEC_FLAGS,
REQUIRED_HARD_ISOLATION_FLAGS,
probeSbxCapabilities,
};
Loading
Loading