Skip to content

Commit ca0eb0f

Browse files
committed
shim: create UDS mount placeholders for the assembled rootfs, not just a bind Source
createSandboxedContainer only created UDS-mount placeholder files by scanning r.Rootfs for a "bind"-typed entry and writing into its Source. That misses the common case entirely: a real CRI overlay snapshotter, `ctr run` with overlayfs, and this repo's own erofs layer format all present the rootfs as a multi-entry overlay/erofs assembly (ext4 scratch + erofs layers + a final "format/mkdir/overlay" mount), never a single "bind" entry, so the loop matched nothing and created zero placeholders. In practice this was masked rather than caught by any current test: the OCI runtime (crun, inside vminitd) auto-creates a missing bind-mount destination file as long as the underlying rootfs stays writable, and every current snapshotter/production rootfs is writable (only a fully-extracted, explicitly read-only bind rootfs -- the non-root shimtest path -- genuinely needs a pre-created placeholder, and that's the one shape the old loop happened to handle). The existing doc comment's premise ("the rootfs will be bind-mounted read-only") was also simply wrong for the writable overlay/erofs case. Fix: udsPlaceholderSource (socketforward.go) inspects only the *last* entry in r.Rootfs -- the one mountutil.All actually mounts at the final assembled path, since every earlier entry (lower layers, ext4 scratch devices) exists purely to feed that last mount. If it's a plain read-only bind, its Source is used before ShareRootfs runs (the only genuinely read-only-after-assembly case, matching prior behavior). Otherwise -- including the common multi-entry overlay/erofs shape, where no single entry's Source is the final tree at all -- the placeholder is written into the assembled rootfs itself (SharedFS.RootfsHostPath), which stays writable and is only available once ShareRootfs has run. Added a regression test (TestCreateRootfsPlaceholders_OverlayShapedRootfs) that exercises exactly the previously-missed shape, plus table-driven coverage of udsPlaceholderSource's mount-shape decision (TestUDSPlaceholderSource). Verified: go build/vet/gofmt clean; golangci-lint across linux/darwin x amd64/arm64 (only the 5 known pre-existing gosec findings); full unit suite; 12/12 integration tests; task test:shim both non-root and as root (root exercises the erofs/overlay rootfs shape this fixes) -- only the pre-existing, unrelated ResourceReleaseOnShutdown flake, identical before and after this change; cross-platform build (linux/darwin/windows x amd64/arm64); verify-vendor clean. Signed-off-by: Derek McGowan <derek@mcg.dev>
1 parent 419cb06 commit ca0eb0f

4 files changed

Lines changed: 203 additions & 15 deletions

File tree

internal/shim/sandbox/sharedfs.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,14 @@ func GuestVolumePath(containerID string, n int) string {
101101
return path.Join(GuestContainersDir, containerID, "volumes", fmt.Sprintf("%d", n))
102102
}
103103

104+
// RootfsHostPath returns the host-side path where ShareRootfs assembles the
105+
// container's rootfs (the same directory GuestRootfsPath(containerID)
106+
// exposes to the guest via the virtiofs share). Only meaningful after
107+
// ShareRootfs has returned successfully for containerID.
108+
func (s *SharedFS) RootfsHostPath(containerID string) string {
109+
return filepath.Join(s.root, containerID, "rootfs")
110+
}
111+
104112
// ShareRootfs resolves the container rootfs from the given containerd mount
105113
// specs by executing them on the host inside the shim's mount namespace, and
106114
// exposes the result in the shared filesystem tree so the guest can access it

internal/shim/task/service.go

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -351,10 +351,7 @@ func (s *service) createSandboxedContainer(ctx context.Context, r *taskAPI.Creat
351351
}
352352
sharedNS := &sharedNamespaces{client: vmc}
353353

354-
// Load the OCI bundle and apply per-container transformers. This must
355-
// happen before ShareRootfs so that UDS mount destinations can be
356-
// pre-created in the source rootfs (which is still writable at this
357-
// point) before the read-only bind mount is applied.
354+
// Load the OCI bundle and apply per-container transformers.
358355
var (
359356
ctrNetCfg ctrNetConfig
360357
svm = sandboxVolumeMounter{fs: fs, containerID: r.ID}
@@ -391,26 +388,30 @@ func (s *service) createSandboxedContainer(ctx context.Context, r *taskAPI.Creat
391388

392389
// UDS mounts are rewritten to bind mounts whose source is a socket
393390
// file inside the VM and whose destination is a path in the container
394-
// rootfs (e.g. /run/shared.sock). The OCI runtime requires the
395-
// destination to already exist as a regular file. Since the rootfs
396-
// will be bind-mounted read-only, we create empty placeholder files in
397-
// the SOURCE rootfs directory now, while it is still writable.
398-
for _, m := range r.Rootfs {
399-
if m.Type == "bind" && m.Source != "" {
400-
sfpr.CreateRootfsPlaceholders(ctx, m.Source)
401-
break // placeholders are the same regardless of layer; one source suffices
402-
}
391+
// rootfs (e.g. /run/shared.sock). The OCI runtime requires the
392+
// destination to already exist as a regular file. See
393+
// udsPlaceholderSource's doc comment for why the correct target
394+
// depends on the shape of r.Rootfs: a read-only bind needs its
395+
// placeholder written to the still-writable Source before ShareRootfs
396+
// mounts it read-only; anything else (in particular the common
397+
// overlay/erofs assembly) needs it written to the assembled rootfs
398+
// itself, which is only available after ShareRootfs runs.
399+
placeholderSrc, placeholderBeforeAssembly := udsPlaceholderSource(r.Rootfs, fs.RootfsHostPath(r.ID))
400+
if placeholderBeforeAssembly {
401+
sfpr.CreateRootfsPlaceholders(ctx, placeholderSrc)
403402
}
404403

405404
// Assemble the container rootfs on the host inside the shared dir.
406-
// Done after bundle loading so UDS placeholders are in place before the
407-
// read-only bind mount is applied.
408405
guestRootfs, err := fs.ShareRootfs(ctx, r.ID, r.Rootfs)
409406
if err != nil {
410407
fs.Unshare(ctx, r.ID) //nolint:errcheck
411408
return nil, errgrpc.ToGRPC(fmt.Errorf("share rootfs for %s: %w", r.ID, err))
412409
}
413410

411+
if !placeholderBeforeAssembly {
412+
sfpr.CreateRootfsPlaceholders(ctx, placeholderSrc)
413+
}
414+
414415
nwJSON, err := json.Marshal(ctrNetCfg)
415416
if err != nil {
416417
fs.Unshare(ctx, r.ID) //nolint:errcheck

internal/shim/task/socketforward.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,10 @@ import (
2525
"net"
2626
"os"
2727
"path/filepath"
28+
"slices"
2829
"strings"
2930

31+
"github.com/containerd/containerd/api/types"
3032
"github.com/containerd/continuity/fs"
3133
"github.com/containerd/log"
3234
"github.com/opencontainers/runtime-spec/specs-go"
@@ -171,6 +173,41 @@ func (p *socketForwardsProvider) CreateRootfsPlaceholders(ctx context.Context, s
171173
}
172174
}
173175

176+
// udsPlaceholderSource returns the writable directory where UDS mount
177+
// placeholder files must be created for a container whose rootfs is
178+
// assembled from rootfsMounts, and whether that directory is available
179+
// before SharedFS.ShareRootfs assembles the rootfs (beforeAssembly) or only
180+
// after (i.e. assembledRootfs, the host path SharedFS.RootfsHostPath
181+
// returns once ShareRootfs has run).
182+
//
183+
// mountutil.All mounts every entry in rootfsMounts, but only the *last*
184+
// entry ends up at the final assembled path — every other entry (lower
185+
// layers, ext4 scratch devices, etc.) is mounted elsewhere purely to feed
186+
// that last mount (e.g. as overlay lowerdir/upperdir sources). So the only
187+
// mount spec that can tell us anything about the assembled rootfs itself is
188+
// the last one:
189+
//
190+
// - If it is a plain "bind" mount with the "ro" option, ShareRootfs will
191+
// mount its Source read-only at the assembled path, so placeholders
192+
// must be written into that still-writable Source *before* ShareRootfs
193+
// runs — writing into the assembled path afterward would fail with
194+
// EROFS.
195+
// - Otherwise — an overlay/erofs assembly with a writable upperdir, a
196+
// plain writable bind, or anything else mountutil.All supports — the
197+
// assembled path itself stays writable, and is in fact the *only*
198+
// correct target: for a multi-entry rootfs (the common overlay/erofs
199+
// case) no single entry's Source is the final tree, only the assembled
200+
// mountpoint is.
201+
func udsPlaceholderSource(rootfsMounts []*types.Mount, assembledRootfs string) (path string, beforeAssembly bool) {
202+
if len(rootfsMounts) > 0 {
203+
last := rootfsMounts[len(rootfsMounts)-1]
204+
if last.Type == "bind" && last.Source != "" && slices.Contains(last.Options, "ro") {
205+
return last.Source, true
206+
}
207+
}
208+
return assembledRootfs, false
209+
}
210+
174211
// bindSockets calls the Bind RPC on the VM to set up socket forward
175212
// listener sockets. This must be called before container creation so that
176213
// crun can bind-mount the listener sockets into the container.

internal/shim/task/socketforward_test.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"path/filepath"
2323
"testing"
2424

25+
"github.com/containerd/containerd/api/types"
2526
"github.com/opencontainers/runtime-spec/specs-go"
2627
"github.com/stretchr/testify/assert"
2728
"github.com/stretchr/testify/require"
@@ -176,3 +177,144 @@ func TestCreateRootfsPlaceholders_ConfinesToRootfs(t *testing.T) {
176177
// The well-behaved mount's placeholder must still be created normally.
177178
assert.FileExists(t, filepath.Join(sourceRootfs, "run", "normal.sock"))
178179
}
180+
181+
// TestUDSPlaceholderSource covers the mount-shape decision at the heart of
182+
// the sandboxed UDS placeholder fix: only a rootfs whose *last* mount spec
183+
// (the one mountutil.All actually mounts at the assembled path) is a
184+
// read-only bind needs its placeholder written to that mount's Source
185+
// before ShareRootfs runs. Every other shape — in particular a multi-entry
186+
// overlay/erofs assembly, which is what a real snapshotter or this repo's
187+
// erofs layer format actually hands Task.Create — has no single mount
188+
// whose Source is the final assembled tree, so the assembled rootfs path
189+
// itself is the only correct, and only available-after-ShareRootfs, target.
190+
func TestUDSPlaceholderSource(t *testing.T) {
191+
const assembled = "/state/containers/ctr-1/rootfs"
192+
193+
testcases := []struct {
194+
name string
195+
mounts []*types.Mount
196+
wantPath string
197+
wantBefore bool
198+
wantPathReason string
199+
}{
200+
{
201+
name: "no mounts",
202+
mounts: nil,
203+
wantPath: assembled,
204+
wantBefore: false,
205+
wantPathReason: "empty rootfs still assembles an (empty) directory at the guest path",
206+
},
207+
{
208+
name: "read-only bind (non-root shimtest / a committed snapshot)",
209+
mounts: []*types.Mount{
210+
{Type: "bind", Source: "/tmp/extracted-rootfs", Options: []string{"ro", "rbind"}},
211+
},
212+
wantPath: "/tmp/extracted-rootfs",
213+
wantBefore: true,
214+
wantPathReason: "ShareRootfs will mount this Source read-only at the assembled path",
215+
},
216+
{
217+
name: "writable bind (no ro option)",
218+
mounts: []*types.Mount{
219+
{Type: "bind", Source: "/tmp/writable-rootfs", Options: []string{"rbind"}},
220+
},
221+
wantPath: assembled,
222+
wantBefore: false,
223+
wantPathReason: "the bind stays writable, so using the assembled path (equivalent content) after ShareRootfs is correct and simpler",
224+
},
225+
{
226+
name: "bind marked ro but missing Source",
227+
mounts: []*types.Mount{
228+
{Type: "bind", Source: "", Options: []string{"ro"}},
229+
},
230+
wantPath: assembled,
231+
wantBefore: false,
232+
wantPathReason: "an empty Source can't be written to before assembly; fall back to the assembled path",
233+
},
234+
{
235+
name: "overlay/erofs multi-layer assembly (the common CRI/erofs shape)",
236+
mounts: []*types.Mount{
237+
{Type: "ext4", Source: "/state/scratch.ext4", Options: []string{"rw", "loop"}},
238+
{Type: "erofs", Source: "/layers/base.erofs", Options: []string{"ro", "loop"}},
239+
{
240+
Type: "format/mkdir/overlay",
241+
Source: "overlay",
242+
Options: []string{
243+
"workdir={{ mount 0 }}/work",
244+
"upperdir={{ mount 0 }}/upper",
245+
"lowerdir={{ mount 1 }}",
246+
},
247+
},
248+
},
249+
wantPath: assembled,
250+
wantBefore: false,
251+
wantPathReason: "no single mount's Source is the assembled tree; the overlay's writable upperdir backs the assembled path itself",
252+
},
253+
{
254+
name: "single overlay mount with explicit upperdir",
255+
mounts: []*types.Mount{
256+
{
257+
Type: "overlay",
258+
Source: "overlay",
259+
Options: []string{"lowerdir=/l1:/l2", "upperdir=/upper", "workdir=/work"},
260+
},
261+
},
262+
wantPath: assembled,
263+
wantBefore: false,
264+
wantPathReason: "an overlay mount is never type \"bind\", so it must resolve to the assembled path",
265+
},
266+
}
267+
268+
for _, tc := range testcases {
269+
t.Run(tc.name, func(t *testing.T) {
270+
gotPath, gotBefore := udsPlaceholderSource(tc.mounts, assembled)
271+
assert.Equal(t, tc.wantPath, gotPath, tc.wantPathReason)
272+
assert.Equal(t, tc.wantBefore, gotBefore)
273+
})
274+
}
275+
}
276+
277+
// TestCreateRootfsPlaceholders_OverlayShapedRootfs is an end-to-end
278+
// regression test for the bug identified in review: previously, placeholder
279+
// creation only ever scanned r.Rootfs for a "bind"-typed entry, so an
280+
// overlay/erofs-shaped rootfs (no "bind" entry at all — the shape used by
281+
// the erofs snapshotter and any real CRI overlay snapshotter) produced zero
282+
// placeholders, leaving a UDS mount's rewritten bind destination missing.
283+
//
284+
// This test drives the same two-call sequence service.go's
285+
// createSandboxedContainer uses (udsPlaceholderSource to pick a target,
286+
// then CreateRootfsPlaceholders) against an overlay-shaped mount list and a
287+
// writable directory standing in for the host path SharedFS.ShareRootfs
288+
// would have assembled, and asserts the placeholder lands there.
289+
func TestCreateRootfsPlaceholders_OverlayShapedRootfs(t *testing.T) {
290+
ctx := context.Background()
291+
assembledRootfs := t.TempDir()
292+
293+
overlayShapedMounts := []*types.Mount{
294+
{Type: "ext4", Source: "/state/scratch.ext4", Options: []string{"rw", "loop"}},
295+
{Type: "erofs", Source: "/layers/base.erofs", Options: []string{"ro", "loop"}},
296+
{Type: "format/mkdir/overlay", Source: "overlay", Options: []string{
297+
"workdir={{ mount 0 }}/work",
298+
"upperdir={{ mount 0 }}/upper",
299+
"lowerdir={{ mount 1 }}",
300+
}},
301+
}
302+
303+
p := &socketForwardsProvider{
304+
entries: []socketForwardEntry{
305+
{containerPath: "/run/shared.sock"},
306+
},
307+
}
308+
309+
placeholderSrc, beforeAssembly := udsPlaceholderSource(overlayShapedMounts, assembledRootfs)
310+
require.False(t, beforeAssembly, "an overlay-shaped rootfs has no writable Source available before assembly")
311+
require.Equal(t, assembledRootfs, placeholderSrc)
312+
313+
// Mirror service.go: this call only happens after ShareRootfs would
314+
// have assembled the rootfs (here, simply because assembledRootfs
315+
// already exists and is writable).
316+
p.CreateRootfsPlaceholders(ctx, placeholderSrc)
317+
318+
assert.FileExists(t, filepath.Join(assembledRootfs, "run", "shared.sock"),
319+
"UDS placeholder must be created in the assembled rootfs when no mount entry has a usable pre-assembly Source")
320+
}

0 commit comments

Comments
 (0)