Skip to content

Commit 7c33b2f

Browse files
authored
feat(image): ship a reference sandbox image, and publish it from CI (#1)
openblox pulls an absent image on create, but never shipped one, so the default path did not work until you had authored a Dockerfile yourself. image/ is that default: python3, bash, nc and a non-root user on Debian slim. The three things openblox actually requires of an image — a shell, a non-root USER, and nc-or-python3 for the preview relay — are now stated in one place and asserted at build time, rather than being folklore that surfaced as a broken preview. Debian rather than Alpine because musl has no manylinux wheels, so on Alpine every numpy install compiles from source inside the sandbox. CI publishes it to ghcr: a tag pushes that version plus :latest, main pushes :edge, both multi-arch. A pull request builds and asserts the contract without pushing, so a broken Dockerfile is a red check rather than a missing image discovered on main. The digest lands in the job summary, because a tag can be repointed by whoever controls the registry and the image is the sandbox's entire userland. Verified against a real gVisor host: create, python3, bash, non-root, /workspace round-trip, a detached process, and a preview fetched over the relay — which now takes the nc fast path rather than the python3 fallback.
1 parent eea758e commit 7c33b2f

5 files changed

Lines changed: 317 additions & 3 deletions

File tree

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
name: Publish image
2+
3+
# Builds the reference sandbox image and pushes it to ghcr.
4+
#
5+
# openblox pulls an absent image on create, so publishing is what makes the
6+
# default work on a host that has never seen it. A release tag publishes the
7+
# matching image version; main publishes :edge so the tip is always testable
8+
# without cutting a release.
9+
#
10+
# The digest is printed to the job summary. Pin THAT downstream, not the tag: an
11+
# image is the sandbox's entire userland, and whoever controls the registry can
12+
# repoint a tag.
13+
#
14+
# Every ${{ }} value is passed through env rather than interpolated into a run:
15+
# script — workflow_dispatch inputs are attacker-controlled by anyone who can
16+
# trigger the workflow, and this job holds a registry-write token.
17+
18+
on:
19+
pull_request:
20+
paths:
21+
- 'image/**'
22+
- '.github/workflows/publish-image.yml'
23+
push:
24+
branches: [main]
25+
paths:
26+
- 'image/**'
27+
- '.github/workflows/publish-image.yml'
28+
tags: ['v*']
29+
workflow_dispatch:
30+
inputs:
31+
tag:
32+
description: 'Version tag to publish (e.g. 0.1.0). Defaults to :edge.'
33+
type: string
34+
required: false
35+
36+
permissions:
37+
contents: read
38+
packages: write
39+
40+
concurrency:
41+
group: publish-image
42+
cancel-in-progress: false
43+
44+
defaults:
45+
run:
46+
shell: bash
47+
48+
jobs:
49+
# A pull request builds the image and asserts the contract, but never pushes.
50+
# Without this a broken Dockerfile is only discovered after it reaches main,
51+
# where the failure is a missing image rather than a red check.
52+
verify:
53+
name: Build & Verify
54+
if: github.event_name == 'pull_request'
55+
runs-on: ubuntu-latest
56+
timeout-minutes: 20
57+
steps:
58+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
59+
60+
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
61+
62+
# Single-platform and loaded into the local daemon: buildx cannot --load a
63+
# multi-platform manifest, and running the contract check matters more here
64+
# than proving both architectures build.
65+
- uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
66+
with:
67+
context: image
68+
platforms: linux/amd64
69+
push: false
70+
load: true
71+
tags: openblox-sandbox:pr
72+
cache-from: type=gha
73+
cache-to: type=gha,mode=max
74+
75+
- name: Verify the contract
76+
run: |
77+
docker run --rm --entrypoint /bin/sh openblox-sandbox:pr -c \
78+
'command -v bash && command -v python3 && command -v nc && [ "$(id -u)" -ne 0 ]'
79+
80+
publish:
81+
name: Build & Push
82+
if: github.event_name != 'pull_request'
83+
runs-on: ubuntu-latest
84+
timeout-minutes: 30
85+
steps:
86+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
87+
88+
# Resolve the version BEFORE anything can push. A tag push publishes that
89+
# version plus :latest; anything else is :edge. A dispatch override is
90+
# validated, because it reaches a shell holding a write token.
91+
- name: Resolve tags
92+
id: meta
93+
env:
94+
TAG_INPUT: ${{ inputs.tag }}
95+
REF_NAME: ${{ github.ref_name }}
96+
REF_TYPE: ${{ github.ref_type }}
97+
run: |
98+
set -euo pipefail
99+
image="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/openblox-sandbox"
100+
101+
version=""
102+
if [ -n "$TAG_INPUT" ]; then
103+
version="${TAG_INPUT#v}"
104+
elif [ "$REF_TYPE" = tag ]; then
105+
version="${REF_NAME#v}"
106+
fi
107+
108+
if [ -n "$version" ]; then
109+
if ! printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
110+
echo "refusing version '$version': expected semver like 1.2.3" >&2
111+
exit 1
112+
fi
113+
printf 'tags=%s:%s,%s:latest\n' "$image" "$version" "$image" >> "$GITHUB_OUTPUT"
114+
else
115+
printf 'tags=%s:edge\n' "$image" >> "$GITHUB_OUTPUT"
116+
fi
117+
118+
- uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
119+
120+
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
121+
122+
- uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
123+
with:
124+
registry: ghcr.io
125+
username: ${{ github.actor }}
126+
password: ${{ secrets.GITHUB_TOKEN }}
127+
128+
- name: Build and push
129+
id: push
130+
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
131+
with:
132+
context: image
133+
# arm64 as well as amd64: Apple Silicon is where most people will try
134+
# this first, and emulating the whole sandbox to run a demo is not a
135+
# first impression worth having.
136+
platforms: linux/amd64,linux/arm64
137+
push: true
138+
tags: ${{ steps.meta.outputs.tags }}
139+
provenance: true
140+
sbom: true
141+
cache-from: type=gha
142+
cache-to: type=gha,mode=max
143+
144+
# Smoke-test the published image against the contract the Dockerfile
145+
# asserts at build time. The build only proves it held on the BUILD
146+
# platform; this proves the pushed manifest runs.
147+
- name: Verify the pushed image
148+
env:
149+
TAGS: ${{ steps.meta.outputs.tags }}
150+
run: |
151+
set -euo pipefail
152+
ref="${TAGS%%,*}"
153+
docker pull "$ref"
154+
docker run --rm --entrypoint /bin/sh "$ref" -c \
155+
'command -v bash && command -v python3 && command -v nc && [ "$(id -u)" -ne 0 ]'
156+
echo "contract holds for $ref"
157+
158+
- name: Report the digest to pin
159+
env:
160+
DIGEST: ${{ steps.push.outputs.digest }}
161+
TAGS: ${{ steps.meta.outputs.tags }}
162+
run: |
163+
{
164+
echo '## Sandbox image published'
165+
echo
166+
echo 'Tags:'
167+
echo
168+
printf '%s\n' "$TAGS" | tr ',' '\n' | sed 's/^/- `/; s/$/`/'
169+
echo
170+
echo 'Pin this digest rather than a tag:'
171+
echo
172+
echo '```'
173+
printf '%s@%s\n' "ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/openblox-sandbox" "$DIGEST"
174+
echo '```'
175+
} >> "$GITHUB_STEP_SUMMARY"

Makefile

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
.PHONY: all vet lint test test-integration cover tidy
1+
.PHONY: all vet lint test test-integration cover tidy image image-verify
2+
3+
# The reference sandbox image. See image/README.md for the contract it satisfies.
4+
IMAGE ?= openblox-sandbox:dev
25

36
all: vet lint test
47

@@ -20,3 +23,11 @@ cover:
2023

2124
tidy:
2225
go mod tidy
26+
27+
image:
28+
docker build -t $(IMAGE) image/
29+
30+
# The same assertions the publish workflow runs against the pushed manifest.
31+
image-verify:
32+
docker run --rm --entrypoint /bin/sh $(IMAGE) -c \
33+
'command -v bash && command -v python3 && command -v nc && [ "$$(id -u)" -ne 0 ]'

README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ openblox is a small Go library over Docker and [gVisor](https://gvisor.dev). The
66
no control plane, no database, and no scheduler — a sandbox is a container, and
77
the container is the state.
88

9-
> **Status: pre-release.** The API is taking shape and will change. Not yet published.
9+
> **Status: pre-release.** The API is taking shape and will change.
10+
11+
The image above is the [reference sandbox userland](image/README.md) openblox
12+
publishes; any image works, as long as it has a shell, a non-root default user,
13+
and `nc` or `python3`.
1014

1115
```go
1216
backend, err := docker.New()
@@ -17,7 +21,8 @@ defer backend.Close()
1721

1822
// No options: no network, non-root, read-only rootfs, capped CPU/memory/PIDs,
1923
// gVisor runtime, reaped when idle.
20-
sb, err := backend.Create(ctx, "session-1", sandbox.WithImage("python:3.13-slim@sha256:..."))
24+
sb, err := backend.Create(ctx, "session-1",
25+
sandbox.WithImage("ghcr.io/blox-eng/openblox-sandbox:latest"))
2126
if err != nil {
2227
return err
2328
}

image/Dockerfile

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# openblox-sandbox — the reference userland openblox runs untrusted code in.
2+
#
3+
# openblox is a library and will run ANY image; this is the one it ships so that
4+
# `go get` followed by Create() works without first authoring a Dockerfile. It is
5+
# a starting point, not a requirement — build your own and pass WithImage().
6+
#
7+
# The contract an openblox image must satisfy is small, and all of it is here:
8+
#
9+
# /bin/sh openblox replaces the entrypoint with its own idle loop, so
10+
# the image needs a shell but no CMD or ENTRYPOINT of its own.
11+
# a non-root USER sandboxes must not run as uid 0. openblox does its own
12+
# privileged bookkeeping as root explicitly, per exec.
13+
# nc or python3 the preview relay reaches a port inside the sandbox over the
14+
# exec channel, because the container has no network
15+
# interface. It uses nc when present and falls back to
16+
# python3. An image with neither cannot serve previews.
17+
#
18+
# Debian rather than Alpine on purpose: sandboxes run Python, and musl has no
19+
# manylinux wheels, so on Alpine every numpy/pandas install compiles from source
20+
# inside the sandbox.
21+
FROM python:3.12-slim-bookworm
22+
23+
# netcat-openbsd gives the preview relay its fast path; without it the relay
24+
# falls back to python3, which works but pays interpreter startup per connection.
25+
# bash because callers routinely hand the sandbox bash scripts, and /bin/sh on
26+
# Debian is dash.
27+
RUN apt-get update \
28+
&& apt-get install -y --no-install-recommends \
29+
bash \
30+
ca-certificates \
31+
netcat-openbsd \
32+
&& rm -rf /var/lib/apt/lists/*
33+
34+
# uid/gid 1000 is the conventional first non-system user, and pinning it means a
35+
# bind-mounted host directory maps to a predictable owner.
36+
RUN groupadd --gid 1000 sandbox \
37+
&& useradd --uid 1000 --gid 1000 --create-home --shell /bin/bash sandbox
38+
39+
# The workdir callers get by default. openblox resolves relative paths against
40+
# it, so it must exist and be writable by the sandbox user.
41+
RUN mkdir -p /workspace && chown sandbox:sandbox /workspace
42+
WORKDIR /workspace
43+
44+
# Fail the build rather than ship an image that silently cannot serve previews
45+
# or run code. This is the contract above, asserted.
46+
RUN command -v sh && command -v bash && command -v python3 && command -v nc
47+
48+
USER sandbox
49+
50+
# Deliberately no ENTRYPOINT or CMD: openblox sets its own. An image that
51+
# insists on a foreground process of its own would be overridden anyway.

image/README.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# openblox-sandbox
2+
3+
The reference image openblox runs sandboxes in, published to
4+
`ghcr.io/blox-eng/openblox-sandbox`.
5+
6+
You do not have to use it. openblox runs any image you point it at:
7+
8+
```go
9+
sb, err := backend.Create(ctx, "my-sandbox", sandbox.WithImage("my-own-image:1.0"))
10+
```
11+
12+
This one exists so that the default works — `Create()` with no options pulls it
13+
and runs.
14+
15+
## Tags
16+
17+
| Tag | What it is |
18+
|-----|------------|
19+
| `:latest` | the most recent release |
20+
| `:X.Y.Z` | that release, immutable by convention |
21+
| `:edge` | tip of `main`, republished whenever `image/` changes |
22+
23+
**Pin the digest, not the tag.** The image is the sandbox's entire userland, so
24+
it is worth the same scrutiny as a dependency:
25+
26+
```
27+
ghcr.io/blox-eng/openblox-sandbox@sha256:...
28+
```
29+
30+
Every publish prints the digest to its job summary.
31+
32+
## The contract
33+
34+
An openblox image must provide:
35+
36+
- **`/bin/sh`** — openblox replaces the entrypoint with its own idle loop, so the
37+
image needs a shell but no `CMD`/`ENTRYPOINT` of its own. One it declares is
38+
overridden.
39+
- **a non-root default `USER`** — sandboxes must not run as uid 0. openblox does
40+
its own privileged bookkeeping as root explicitly, per exec.
41+
- **`nc` or `python3`** — the preview relay reaches a port inside the sandbox
42+
over the exec channel, because the container has no network interface. It
43+
prefers `nc` and falls back to `python3`. An image with neither builds and runs
44+
fine but cannot serve previews.
45+
46+
The Dockerfile asserts all of it at build time, and the publish workflow
47+
re-asserts it against the pushed manifest.
48+
49+
## What is in it
50+
51+
`python:3.12-slim-bookworm`, plus `bash`, `netcat-openbsd`, and `ca-certificates`,
52+
running as `sandbox` (uid/gid 1000) with a writable `/workspace`.
53+
54+
Debian rather than Alpine deliberately: sandboxes run Python, musl has no
55+
manylinux wheels, and on Alpine every `numpy`/`pandas` install compiles from
56+
source inside the sandbox.
57+
58+
## Building it yourself
59+
60+
```sh
61+
make image # build locally as openblox-sandbox:dev
62+
make image-verify # assert the contract against that build
63+
```
64+
65+
Layer your own on top:
66+
67+
```dockerfile
68+
FROM ghcr.io/blox-eng/openblox-sandbox:latest
69+
USER root
70+
RUN pip install --no-cache-dir pandas
71+
USER sandbox
72+
```

0 commit comments

Comments
 (0)