Skip to content

Commit f924522

Browse files
committed
feat(docker): pull the sandbox image when it is absent
Create went straight to ContainerCreate, so a host without the image already on it failed with a bare "No such image: ...". That made every deployment need a manual build or pull first, and made publishing an image to a registry pointless — nothing would ever fetch it. Pull-if-absent, not always-pull. The image is the guest's entire userland, so re-resolving a mutable tag on every create would let the code running in a sandbox change without anything in the caller's configuration changing. Absent means fetch; present means use what is here. A test asserts the present case does not re-resolve. The pull body is drained rather than discarded early: the daemon does the work as the stream is read, so returning at the first byte leaves the image half-fetched and the create failing right behind it. The result is then confirmed by inspect, because the pull API reports some failures inside a 200 response. ErrImageUnavailable is its own sentinel rather than ErrInvalid. The reference may be perfectly well-formed and the registry simply down — one is worth retrying and the other never is. Also adds IsDigestPinned, since "pin a digest" is advice ARCHITECTURE.md gives and nothing could previously check.
1 parent 0b69c69 commit f924522

6 files changed

Lines changed: 193 additions & 0 deletions

File tree

ARCHITECTURE.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,13 @@ Stated plainly, because a security section that only lists wins is marketing:
154154
- A gVisor escape. We inherit gVisor's threat model and its CVEs.
155155
- A malicious or backdoored sandbox **image**. Image supply chain is the caller's
156156
responsibility — pin digests.
157+
158+
openblox pulls an image when it is absent and otherwise uses what is already on
159+
the host. Pull-if-absent, not always-pull, is the deliberate choice: the image
160+
is the guest's entire userland, so re-resolving a mutable tag on every create
161+
would let the code running in a sandbox change without anything in the caller's
162+
configuration changing. The corollary is that a tag is only as trustworthy as
163+
the moment it was first pulled, which is why `WithImage` says to pin a digest.
157164
- Side channels between co-resident sandboxes on the same host.
158165
- Anything the caller does with the results. openblox contains execution; it does
159166
not sanitise output.

pkg/docker/backend.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,9 @@ func (b *Backend) Create(ctx context.Context, name string, opts ...sandbox.Creat
109109
if err := b.assertRuntime(ctx, spec.Runtime); err != nil {
110110
return nil, err
111111
}
112+
if err := b.ensureImage(ctx, spec.Image); err != nil {
113+
return nil, err
114+
}
112115

113116
cfg, hostCfg := buildConfig(name, spec)
114117
created, err := b.cli.ContainerCreate(ctx, cfg, hostCfg, nil, nil, containerName(name))

pkg/docker/image.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package docker
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"strings"
8+
9+
cerrdefs "github.com/containerd/errdefs"
10+
"github.com/docker/docker/api/types/image"
11+
12+
"github.com/blox-eng/openblox/pkg/sandbox"
13+
)
14+
15+
// ensureImage makes the image available locally, pulling it if it is absent.
16+
//
17+
// Pull-if-absent rather than always-pull, deliberately. A sandbox image is the
18+
// guest's entire userland, so re-resolving a mutable tag on every Create would
19+
// mean the code running in the sandbox could change under the caller without
20+
// anything in their configuration changing. Absent means fetch; present means
21+
// use what is here.
22+
//
23+
// The corollary is that a tag is only as trustworthy as the moment it was first
24+
// pulled. Pin a digest — see [sandbox.WithImage] — and this becomes exact.
25+
func (b *Backend) ensureImage(ctx context.Context, ref string) error {
26+
if _, err := b.cli.ImageInspect(ctx, ref); err == nil {
27+
return nil
28+
} else if !cerrdefs.IsNotFound(err) {
29+
return fmt.Errorf("inspect image %q: %w", ref, err)
30+
}
31+
32+
body, err := b.cli.ImagePull(ctx, ref, image.PullOptions{})
33+
if err != nil {
34+
return fmt.Errorf("%w: pull image %q: %w", sandbox.ErrImageUnavailable, ref, err)
35+
}
36+
defer body.Close()
37+
38+
// The pull is only complete once the response body is drained: the daemon
39+
// streams progress and does the work as it is read, so returning early would
40+
// leave the image half-fetched and the create failing right behind it.
41+
if _, err := io.Copy(io.Discard, body); err != nil {
42+
return fmt.Errorf("%w: pull image %q: %w", sandbox.ErrImageUnavailable, ref, err)
43+
}
44+
45+
// Confirm rather than trust the stream: the pull API reports some failures
46+
// inside the progress body with a 200 status.
47+
if _, err := b.cli.ImageInspect(ctx, ref); err != nil {
48+
return fmt.Errorf("%w: image %q is still absent after pulling", sandbox.ErrImageUnavailable, ref)
49+
}
50+
return nil
51+
}
52+
53+
// IsDigestPinned reports whether ref names an image by digest rather than by a
54+
// tag. A tag can be repointed by whoever controls the registry; a digest cannot.
55+
func IsDigestPinned(ref string) bool {
56+
at := strings.LastIndex(ref, "@")
57+
if at < 0 {
58+
return false
59+
}
60+
return strings.HasPrefix(ref[at+1:], "sha256:")
61+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
//go:build integration
2+
3+
package docker
4+
5+
import (
6+
"context"
7+
"errors"
8+
"os/exec"
9+
"testing"
10+
11+
"github.com/blox-eng/openblox/pkg/sandbox"
12+
)
13+
14+
// pullTestImage is small, stable, and not the image the rest of the suite uses,
15+
// so removing it locally cannot disturb anything else.
16+
const pullTestImage = "alpine:3.19"
17+
18+
// A sandbox host is not required to have the image already. Before this, Create
19+
// failed with a bare "No such image" and every deployment needed a manual build
20+
// or pull first.
21+
func TestCreatePullsAnAbsentImage(t *testing.T) {
22+
b := newTestBackend(t)
23+
ctx := context.Background()
24+
25+
// Start from a genuinely absent image, or the test proves nothing.
26+
_ = exec.Command("docker", "rmi", "-f", pullTestImage).Run()
27+
if err := exec.Command("docker", "image", "inspect", pullTestImage).Run(); err == nil {
28+
t.Skipf("%s is still present (in use by another container?); cannot prove the pull", pullTestImage)
29+
}
30+
31+
name := "openblox-test-pull"
32+
_, err := b.Create(ctx, name, sandbox.WithImage(pullTestImage))
33+
if err != nil {
34+
t.Fatalf("Create with an absent image = %v, want it pulled", err)
35+
}
36+
t.Cleanup(func() { _ = b.Destroy(context.Background(), name) })
37+
38+
if err := exec.Command("docker", "image", "inspect", pullTestImage).Run(); err != nil {
39+
t.Errorf("%s is still absent after a successful Create", pullTestImage)
40+
}
41+
}
42+
43+
// An image that cannot be obtained is its own failure, distinct from a malformed
44+
// request: the reference may be perfectly valid and the registry simply down.
45+
func TestCreateReportsAnUnobtainableImage(t *testing.T) {
46+
b := newTestBackend(t)
47+
48+
_, err := b.Create(context.Background(), "openblox-test-noimage",
49+
sandbox.WithImage("ghcr.io/blox-eng/openblox-does-not-exist:0.0.0"))
50+
51+
if !errors.Is(err, sandbox.ErrImageUnavailable) {
52+
t.Fatalf("Create with an unobtainable image = %v, want ErrImageUnavailable", err)
53+
}
54+
}
55+
56+
// A present image must not be re-resolved on every create. The image is the
57+
// guest's whole userland, so a mutable tag silently changing under a caller is a
58+
// supply-chain surprise, not a convenience.
59+
func TestCreateDoesNotRePullAPresentImage(t *testing.T) {
60+
b := newTestBackend(t)
61+
ctx := context.Background()
62+
63+
before := imageID(t, testImage)
64+
name := "openblox-test-nopull"
65+
if _, err := b.Create(ctx, name, sandbox.WithImage(testImage)); err != nil {
66+
t.Fatalf("Create = %v", err)
67+
}
68+
t.Cleanup(func() { _ = b.Destroy(context.Background(), name) })
69+
70+
if after := imageID(t, testImage); after != before {
71+
t.Errorf("image id changed %s -> %s; a present image was re-resolved", before, after)
72+
}
73+
}
74+
75+
func imageID(t *testing.T, ref string) string {
76+
t.Helper()
77+
out, err := exec.Command("docker", "image", "inspect", "-f", "{{.Id}}", ref).Output()
78+
if err != nil {
79+
t.Fatalf("inspect %s = %v", ref, err)
80+
}
81+
return string(out)
82+
}

pkg/docker/image_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package docker
2+
3+
import "testing"
4+
5+
// A tag can be repointed by whoever controls the registry; a digest cannot. The
6+
// image is the guest's entire userland, so the distinction is the difference
7+
// between running what you reviewed and running whatever is there today.
8+
func TestIsDigestPinned(t *testing.T) {
9+
pinned := []string{
10+
"alpine@sha256:abc123",
11+
"ghcr.io/blox-eng/blox-sandbox@sha256:deadbeef",
12+
"registry.example.test:5000/team/img@sha256:0011",
13+
}
14+
for _, ref := range pinned {
15+
if !IsDigestPinned(ref) {
16+
t.Errorf("IsDigestPinned(%q) = false, want true", ref)
17+
}
18+
}
19+
20+
loose := []string{
21+
"alpine",
22+
"alpine:3.20",
23+
"ghcr.io/blox-eng/blox-sandbox:0.2.0",
24+
"registry.example.test:5000/team/img",
25+
// A port in the host is not a digest, and neither is an empty one.
26+
"example.test:5000/img@",
27+
"example.test:5000/img@md5:abc",
28+
}
29+
for _, ref := range loose {
30+
if IsDigestPinned(ref) {
31+
t.Errorf("IsDigestPinned(%q) = true, want false", ref)
32+
}
33+
}
34+
}

pkg/sandbox/errors.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ var (
2525
// isolation. A sandbox that is quietly less isolated than requested is worse
2626
// than no sandbox, because the caller keeps trusting it.
2727
ErrRuntimeUnavailable = errors.New("required runtime unavailable")
28+
29+
// ErrImageUnavailable means the sandbox image could not be obtained: it is
30+
// absent locally and could not be pulled. Distinct from ErrInvalid because
31+
// the request was well-formed and retrying may well succeed — a registry
32+
// being unreachable is a different problem from a misspelled reference.
33+
ErrImageUnavailable = errors.New("sandbox image unavailable")
2834
)
2935

3036
func newInvalidError(format string, args ...any) error {

0 commit comments

Comments
 (0)