Skip to content

Commit 5d87b35

Browse files
Add standalone binary for arbitrary Dockerfile support (#280)
* Add standalone binary for arbitrary Dockerfile support Compiles sandbox-container to a self-contained binary at /sandbox that can be copied into any Docker image. Includes backwards compatibility for existing startup scripts via legacy JS bundle. * Move binary to /container-server/sandbox * Add --platform flag to docker create commands * Fix signal handling and refactor server lifecycle Signal handlers are now registered before spawning the child process to close a race window. Exit codes use os.constants.signals for correct Unix convention mapping. Server cleanup is returned from startServer() rather than relying on module-level state, making the dependency between server startup and shutdown handlers explicit. * Switch base images from Alpine to glibc for binary compat The standalone binary compiled on glibc won't run on Alpine (musl). Using node:20-slim and oven/bun:1 ensures the binary works on standard Linux distributions like Debian, Ubuntu, and RHEL. * Add E2E tests for standalone binary pattern Tests that the sandbox binary works when copied into arbitrary Docker images. Validates command execution, file operations with MIME type detection, and CMD passthrough to user startup scripts. * Document standalone binary pattern and dependencies Documents how to add sandbox capabilities to arbitrary Docker images by copying the /sandbox binary. Lists required dependencies (file, git) and what works without extra packages. * Add CMD passthrough docs and release checksums Document the supervisor lifecycle model for standalone binary users. Add SHA256 checksum generation for binary releases.
1 parent 1d9d882 commit 5d87b35

24 files changed

+692
-134
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@cloudflare/sandbox': patch
3+
---
4+
5+
Add standalone binary support for arbitrary Dockerfiles
6+
7+
Users can now add sandbox capabilities to any Docker image:
8+
9+
```dockerfile
10+
FROM your-image:tag
11+
12+
COPY --from=cloudflare/sandbox:VERSION /container-server/sandbox /sandbox
13+
ENTRYPOINT ["/sandbox"]
14+
15+
# Optional: run your own startup command
16+
CMD ["/your-entrypoint.sh"]
17+
```
18+
19+
The `/sandbox` binary starts the HTTP API server, then executes any CMD as a child process with signal forwarding.
20+
21+
Includes backwards compatibility for existing custom startup scripts.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
### 🐳 Docker Images Published
2+
3+
**Default:**
4+
5+
```dockerfile
6+
FROM {{DEFAULT_TAG}}
7+
```
8+
9+
**With Python:**
10+
11+
```dockerfile
12+
FROM {{PYTHON_TAG}}
13+
```
14+
15+
**With OpenCode:**
16+
17+
```dockerfile
18+
FROM {{OPENCODE_TAG}}
19+
```
20+
21+
**Version:** `{{VERSION}}`
22+
23+
Use the `-python` variant if you need Python code execution, or `-opencode` for the variant with OpenCode AI coding agent pre-installed.
24+
25+
---
26+
27+
### 📦 Standalone Binary
28+
29+
**For arbitrary Dockerfiles:**
30+
31+
```dockerfile
32+
COPY --from={{DEFAULT_TAG}} /container-server/sandbox /sandbox
33+
ENTRYPOINT ["/sandbox"]
34+
```
35+
36+
**Download via GitHub CLI:**
37+
38+
```bash
39+
gh run download {{RUN_ID}} -n sandbox-binary
40+
```
41+
42+
**Extract from Docker:**
43+
44+
```bash
45+
docker run --rm {{DEFAULT_TAG}} cat /container-server/sandbox > sandbox && chmod +x sandbox
46+
```

.github/workflows/pkg-pr-new.yml

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,18 +125,42 @@ jobs:
125125
build-args: |
126126
SANDBOX_VERSION=${{ steps.package-version.outputs.version }}
127127
128+
- name: Extract standalone binary from Docker image
129+
run: |
130+
VERSION=${{ steps.package-version.outputs.version }}
131+
CONTAINER_ID=$(docker create --platform linux/amd64 cloudflare/sandbox:$VERSION)
132+
docker cp $CONTAINER_ID:/container-server/sandbox ./sandbox-linux-x64
133+
docker rm $CONTAINER_ID
134+
chmod +x ./sandbox-linux-x64
135+
136+
- name: Upload standalone binary as artifact
137+
uses: actions/upload-artifact@v4
138+
with:
139+
name: sandbox-binary
140+
path: ./sandbox-linux-x64
141+
retention-days: 30
142+
128143
- name: Publish to pkg.pr.new
129144
run: npx pkg-pr-new publish './packages/sandbox'
130145

131146
- name: Comment Docker image tag
132147
uses: actions/github-script@v7
133148
with:
134149
script: |
150+
const fs = require('fs');
135151
const version = '${{ steps.package-version.outputs.version }}';
152+
const runId = '${{ github.run_id }}';
136153
const defaultTag = `cloudflare/sandbox:${version}`;
137154
const pythonTag = `cloudflare/sandbox:${version}-python`;
138155
const opencodeTag = `cloudflare/sandbox:${version}-opencode`;
139-
const body = `### 🐳 Docker Images Published\n\n**Default (no Python):**\n\`\`\`dockerfile\nFROM ${defaultTag}\n\`\`\`\n\n**With Python:**\n\`\`\`dockerfile\nFROM ${pythonTag}\n\`\`\`\n\n**With OpenCode:**\n\`\`\`dockerfile\nFROM ${opencodeTag}\n\`\`\`\n\n**Version:** \`${version}\`\n\nUse the \`-python\` variant for Python code execution, or \`-opencode\` for the OpenCode AI coding agent.`;
156+
157+
const template = fs.readFileSync('.github/templates/pr-preview-comment.md', 'utf8');
158+
const body = template
159+
.replace(/\{\{VERSION\}\}/g, version)
160+
.replace(/\{\{RUN_ID\}\}/g, runId)
161+
.replace(/\{\{DEFAULT_TAG\}\}/g, defaultTag)
162+
.replace(/\{\{PYTHON_TAG\}\}/g, pythonTag)
163+
.replace(/\{\{OPENCODE_TAG\}\}/g, opencodeTag);
140164
141165
// Find existing comment
142166
const { data: comments } = await github.rest.issues.listComments({

.github/workflows/pullrequest.yml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ jobs:
116116
- name: Set up Docker Buildx
117117
uses: docker/setup-buildx-action@v3
118118

119-
- name: Build test worker Docker images (base + python + opencode)
119+
- name: Build test worker Docker images (base + python + opencode + standalone)
120120
run: |
121121
VERSION=${{ needs.unit-tests.outputs.version || '0.0.0' }}
122122
# Build base image (no Python) - used by Sandbox binding
@@ -128,6 +128,16 @@ jobs:
128128
# Build opencode image - used by SandboxOpencode binding
129129
docker build -f packages/sandbox/Dockerfile --target opencode --platform linux/amd64 \
130130
--build-arg SANDBOX_VERSION=$VERSION -t cloudflare/sandbox-test:$VERSION-opencode .
131+
# Build standalone image (arbitrary base with binary) - used by SandboxStandalone binding
132+
# Use regex to replace any version number, avoiding hardcoded version mismatch
133+
# Build from test-worker directory so COPY startup-test.sh works
134+
cd tests/e2e/test-worker
135+
sed -E "s|cloudflare/sandbox-test:[0-9]+\.[0-9]+\.[0-9]+|cloudflare/sandbox-test:$VERSION|g" \
136+
Dockerfile.standalone > Dockerfile.standalone.tmp
137+
docker build -f Dockerfile.standalone.tmp --platform linux/amd64 \
138+
-t cloudflare/sandbox-test:$VERSION-standalone .
139+
rm Dockerfile.standalone.tmp
140+
cd ../../..
131141
132142
# Deploy test worker using official Cloudflare action
133143
- name: Deploy test worker

.github/workflows/release.yml

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ jobs:
108108
- name: Set up Docker Buildx
109109
uses: docker/setup-buildx-action@v3
110110

111-
- name: Build test worker Docker images (base + python + opencode)
111+
- name: Build test worker Docker images (base + python + opencode + standalone)
112112
run: |
113113
VERSION=${{ needs.unit-tests.outputs.version }}
114114
# Build base image (no Python) - used by Sandbox binding
@@ -120,6 +120,16 @@ jobs:
120120
# Build opencode image - used by SandboxOpencode binding
121121
docker build -f packages/sandbox/Dockerfile --target opencode --platform linux/amd64 \
122122
--build-arg SANDBOX_VERSION=$VERSION -t cloudflare/sandbox-test:$VERSION-opencode .
123+
# Build standalone image (arbitrary base with binary) - used by SandboxStandalone binding
124+
# Use regex to replace any version number, avoiding hardcoded version mismatch
125+
# Build from test-worker directory so COPY startup-test.sh works
126+
cd tests/e2e/test-worker
127+
sed -E "s|cloudflare/sandbox-test:[0-9]+\.[0-9]+\.[0-9]+|cloudflare/sandbox-test:$VERSION|g" \
128+
Dockerfile.standalone > Dockerfile.standalone.tmp
129+
docker build -f Dockerfile.standalone.tmp --platform linux/amd64 \
130+
-t cloudflare/sandbox-test:$VERSION-standalone .
131+
rm Dockerfile.standalone.tmp
132+
cd ../../..
123133
124134
- name: Deploy test worker
125135
uses: cloudflare/wrangler-action@v3
@@ -230,6 +240,15 @@ jobs:
230240
build-args: |
231241
SANDBOX_VERSION=${{ needs.unit-tests.outputs.version }}
232242
243+
- name: Extract standalone binary from Docker image
244+
run: |
245+
VERSION=${{ needs.unit-tests.outputs.version }}
246+
CONTAINER_ID=$(docker create --platform linux/amd64 cloudflare/sandbox:$VERSION)
247+
docker cp $CONTAINER_ID:/container-server/sandbox ./sandbox-linux-x64
248+
docker rm $CONTAINER_ID
249+
file ./sandbox-linux-x64
250+
ls -la ./sandbox-linux-x64
251+
233252
- id: changesets
234253
uses: changesets/action@v1
235254
with:
@@ -238,3 +257,13 @@ jobs:
238257
env:
239258
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
240259
NPM_CONFIG_PROVENANCE: true
260+
261+
- name: Upload standalone binary to GitHub release
262+
if: steps.changesets.outputs.published == 'true'
263+
env:
264+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
265+
run: |
266+
VERSION=${{ needs.unit-tests.outputs.version }}
267+
sha256sum sandbox-linux-x64 > sandbox-linux-x64.sha256
268+
# Tag format matches changesets: @cloudflare/sandbox@VERSION
269+
gh release upload "@cloudflare/sandbox@${VERSION}" ./sandbox-linux-x64 ./sandbox-linux-x64.sha256 --clobber

docs/STANDALONE_BINARY.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Standalone Binary Pattern
2+
3+
Add Cloudflare Sandbox capabilities to any Docker image by copying the `/sandbox` binary.
4+
5+
## Basic Usage
6+
7+
```dockerfile
8+
FROM node:20-slim
9+
10+
# Required: install 'file' for SDK file operations
11+
RUN apt-get update && apt-get install -y --no-install-recommends file \
12+
&& rm -rf /var/lib/apt/lists/*
13+
14+
COPY --from=cloudflare/sandbox:latest /container-server/sandbox /sandbox
15+
16+
ENTRYPOINT ["/sandbox"]
17+
CMD ["/your-startup-script.sh"] # Optional: runs after server starts
18+
```
19+
20+
## How CMD Passthrough Works
21+
22+
The `/sandbox` binary acts as a supervisor:
23+
24+
1. Starts HTTP API server on port 3000
25+
2. Spawns your CMD as a child process
26+
3. Forwards SIGTERM/SIGINT to the child
27+
4. If CMD exits 0, server keeps running; non-zero exits terminate the container
28+
29+
## Required Dependencies
30+
31+
| Dependency | Required For | Install Command |
32+
| ---------- | ----------------------------------------------- | ---------------------- |
33+
| `file` | `readFile()`, `writeFile()`, any file operation | `apt-get install file` |
34+
| `git` | `gitCheckout()`, `listBranches()` | `apt-get install git` |
35+
| `bash` | Everything (core requirement) | Usually pre-installed |
36+
37+
Most base images (node:slim, python:slim, ubuntu) include everything except `file` and `git`.
38+
39+
## What Works Without Extra Dependencies
40+
41+
- `exec()` - Run shell commands
42+
- `startProcess()` - Background processes
43+
- `exposePort()` - Expose services
44+
45+
## Troubleshooting
46+
47+
**"Failed to detect MIME type"** - Install `file`
48+
49+
**"git: command not found"** - Install `git` (only needed for git operations)
50+
51+
**Commands hang** - Ensure `bash` exists at `/bin/bash`
52+
53+
## Note on Code Interpreter
54+
55+
`runCode()` requires Python/Node executors not included in the standalone binary. Use the official sandbox images for code interpreter support.

packages/sandbox-container/build.ts

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,44 @@
11
/**
22
* Build script for sandbox-container using Bun's bundler.
3-
* Bundles the container server and JS executor into standalone files.
3+
* Produces:
4+
* - dist/sandbox: Standalone binary for /sandbox entrypoint
5+
* - dist/index.js: Legacy JS bundle for backwards compatibility
6+
* - dist/runtime/executors/javascript/node_executor.js: JS executor
47
*/
58

69
import { mkdir } from 'node:fs/promises';
710

811
// Ensure output directories exist
912
await mkdir('dist/runtime/executors/javascript', { recursive: true });
1013

11-
console.log('Building container server bundle...');
14+
// Build legacy JS bundle for backwards compatibility
15+
// Users with custom startup scripts that call `bun /container-server/dist/index.js` need this
16+
console.log('Building legacy JS bundle...');
1217

13-
// Bundle the main container server
14-
const serverResult = await Bun.build({
15-
entrypoints: ['src/index.ts'],
18+
const legacyResult = await Bun.build({
19+
entrypoints: ['src/legacy.ts'],
1620
outdir: 'dist',
1721
target: 'bun',
1822
minify: true,
19-
sourcemap: 'external'
23+
sourcemap: 'external',
24+
naming: 'index.js'
2025
});
2126

22-
if (!serverResult.success) {
23-
console.error('Server build failed:');
24-
for (const log of serverResult.logs) {
27+
if (!legacyResult.success) {
28+
console.error('Legacy bundle build failed:');
29+
for (const log of legacyResult.logs) {
2530
console.error(log);
2631
}
2732
process.exit(1);
2833
}
2934

3035
console.log(
31-
` dist/index.js (${(serverResult.outputs[0].size / 1024).toFixed(1)} KB)`
36+
` dist/index.js (${(legacyResult.outputs[0].size / 1024).toFixed(1)} KB)`
3237
);
3338

3439
console.log('Building JavaScript executor...');
3540

36-
// Bundle the JS executor (runs on Node, not Bun)
41+
// Bundle the JS executor (runs on Node or Bun for code interpreter)
3742
const executorResult = await Bun.build({
3843
entrypoints: ['src/runtime/executors/javascript/node_executor.ts'],
3944
outdir: 'dist/runtime/executors/javascript',
@@ -54,4 +59,34 @@ console.log(
5459
` dist/runtime/executors/javascript/node_executor.js (${(executorResult.outputs[0].size / 1024).toFixed(1)} KB)`
5560
);
5661

62+
console.log('Building standalone binary...');
63+
64+
// Compile standalone binary (bundles Bun runtime)
65+
const proc = Bun.spawn(
66+
[
67+
'bun',
68+
'build',
69+
'src/main.ts',
70+
'--compile',
71+
'--target=bun-linux-x64',
72+
'--outfile=dist/sandbox',
73+
'--minify'
74+
],
75+
{
76+
cwd: process.cwd(),
77+
stdio: ['inherit', 'inherit', 'inherit']
78+
}
79+
);
80+
81+
const exitCode = await proc.exited;
82+
if (exitCode !== 0) {
83+
console.error('Standalone binary build failed');
84+
process.exit(1);
85+
}
86+
87+
// Get file size
88+
const file = Bun.file('dist/sandbox');
89+
const size = file.size;
90+
console.log(` dist/sandbox (${(size / 1024 / 1024).toFixed(1)} MB)`);
91+
5792
console.log('Build complete!');

0 commit comments

Comments
 (0)