Skip to content

Commit fcdde9b

Browse files
committed
feat: mount OCI-SIF overlays and support --writable
When an OCI-SIF contains an ext3 overlay layer, created with `singularity overlay create`, it is now mounted when the container is run. By default, an embedded overlay is mounted read-only. In OCI-Mode we have a `--writable-tmpfs` in place by default, so changes can still be made to the container at runtime, but they are made in the ephemeral tmpfs, and do not write into the overlay. To write changes into the embedded overlay, the container must be started with the `--writable` flag, which is now supported in OCI-Mode. Embedded overlay layers are handled by extending the code that previously dealt with user specified `--overlay`s. Note that fuse2fs >=1.46.6 is required to use an embedded overlay, as older versions do not support the `-o` (offset) flag. Closes #2868 Closes #2869
1 parent 9b26273 commit fcdde9b

File tree

10 files changed

+213
-50
lines changed

10 files changed

+213
-50
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020
image to be pushed to `library://` and `docker://` registries in `squashfs`
2121
(default) or `tar` format. Images pushed with `--layer-format tar` can be
2222
pulled and run by other OCI runtimes.
23+
- A writable overlay can be added to an OCI-SIF file with the `singularity
24+
overlay create` command. The overlay will be applied read-only, by default,
25+
when executing the OCI-SIF. To write changes to the container into the overlay,
26+
use the `--writable` flag.
2327

2428
### Bug Fixes
2529

e2e/overlay/overlay.go

Lines changed: 87 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -189,8 +189,10 @@ func (c ctx) testOverlayCreate(t *testing.T) {
189189
}
190190
}
191191

192-
func (c ctx) testOverlayCreateOCI(t *testing.T) {
193-
require.Filesystem(t, "overlay")
192+
func (c ctx) testOverlayOCI(t *testing.T) {
193+
require.Command(t, "fuse2fs")
194+
require.Command(t, "fuse-overlayfs")
195+
require.Command(t, "fusermount")
194196
require.MkfsExt3(t)
195197
e2e.EnsureOCISIF(t, c.env)
196198

@@ -240,6 +242,13 @@ func (c ctx) testOverlayCreateOCI(t *testing.T) {
240242
// native SIF tests above. Same code path for OCI-SIF. We don't need to
241243
// repeat them here.
242244
tests := []test{
245+
{
246+
name: "create fail signed",
247+
profile: e2e.UserProfile,
248+
command: "overlay",
249+
args: []string{"create", ocisifSigned},
250+
exit: 255,
251+
},
243252
{
244253
name: "create",
245254
profile: e2e.UserProfile,
@@ -254,12 +263,81 @@ func (c ctx) testOverlayCreateOCI(t *testing.T) {
254263
args: []string{"create", ocisif},
255264
exit: 255,
256265
},
266+
// Add a file without `--writable` - should go into ephemeral tmpfs, not the overlay.
257267
{
258-
name: "create fail signed",
259-
profile: e2e.UserProfile,
260-
command: "overlay",
261-
args: []string{"create", ocisifSigned},
262-
exit: 255,
268+
name: "tmpfs touch",
269+
profile: e2e.OCIUserProfile,
270+
command: "exec",
271+
args: []string{ocisif, "touch", "/in-overlay"},
272+
exit: 0,
273+
},
274+
{
275+
name: "tmpfs check",
276+
profile: e2e.OCIUserProfile,
277+
command: "exec",
278+
args: []string{ocisif, "ls", "/in-overlay"},
279+
exit: 1,
280+
},
281+
282+
// Add a file to the overlay with `--writable` and check that it exists on re-run.
283+
{
284+
name: "writable touch",
285+
profile: e2e.OCIUserProfile,
286+
command: "exec",
287+
args: []string{"--writable", ocisif, "touch", "/in-overlay"},
288+
exit: 0,
289+
},
290+
{
291+
name: "writable touch check",
292+
profile: e2e.OCIUserProfile,
293+
command: "exec",
294+
args: []string{ocisif, "ls", "/in-overlay"},
295+
exit: 0,
296+
},
297+
// Remove file without `--writable` - should be an ephemeral change, file still in overlay.
298+
{
299+
name: "tmpfs rm",
300+
profile: e2e.OCIUserProfile,
301+
command: "exec",
302+
args: []string{ocisif, "rm", "/in-overlay"},
303+
exit: 0,
304+
},
305+
{
306+
name: "tmpfs rm check",
307+
profile: e2e.OCIUserProfile,
308+
command: "exec",
309+
args: []string{ocisif, "ls", "/in-overlay"},
310+
exit: 0,
311+
},
312+
// Remove file with `--writable` - file gone from overlay.
313+
{
314+
name: "writable rm",
315+
profile: e2e.OCIUserProfile,
316+
command: "exec",
317+
args: []string{"--writable", ocisif, "rm", "/in-overlay"},
318+
exit: 0,
319+
},
320+
{
321+
name: "writable rm check",
322+
profile: e2e.OCIUserProfile,
323+
command: "exec",
324+
args: []string{ocisif, "ls", "/in-overlay"},
325+
exit: 1,
326+
},
327+
// Touch file without `--writable` and no tmpfs (via --no-compat)... should fail
328+
{
329+
name: "readonly touch",
330+
profile: e2e.OCIUserProfile,
331+
command: "exec",
332+
args: []string{"--no-compat", ocisif, "touch", "/in-overlay"},
333+
exit: 1,
334+
},
335+
{
336+
name: "readonly touch check",
337+
profile: e2e.OCIUserProfile,
338+
command: "exec",
339+
args: []string{ocisif, "ls", "/in-overlay"},
340+
exit: 1,
263341
},
264342
}
265343

@@ -282,7 +360,7 @@ func E2ETests(env e2e.TestEnv) testhelper.Tests {
282360
}
283361

284362
return testhelper.Tests{
285-
"create": c.testOverlayCreate,
286-
"createOCI": c.testOverlayCreateOCI,
363+
"create": c.testOverlayCreate,
364+
"OCI": c.testOverlayOCI,
287365
}
288366
}

internal/app/singularity/overlay_create.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ func canAddOverlay(img *image.Image) (bool, error) {
9292
return false, errOverlaySigned
9393
}
9494

95-
hasOverlay, err := ocisif.HasOverlay(img.Path)
95+
hasOverlay, _, err := ocisif.HasOverlay(img.Path)
9696
if err != nil {
9797
return false, fmt.Errorf("while checking for overlays: %s", err)
9898
} else if hasOverlay {

internal/pkg/ocisif/overlay.go

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,47 +22,62 @@ import (
2222
var Ext3LayerMediaType types.MediaType = "application/vnd.sylabs.image.layer.v1.ext3"
2323

2424
// HasOverlay returns whether the OCI-SIF at imgPath has an ext3 writable final
25-
// layer - an 'overlay'.
26-
func HasOverlay(imagePath string) (bool, error) {
25+
// layer - an 'overlay'. If present, the offset of the overlay data in the
26+
// OCI-SIF file is also returned.
27+
func HasOverlay(imagePath string) (bool, int64, error) {
2728
fi, err := sif.LoadContainerFromPath(imagePath,
2829
sif.OptLoadWithFlag(os.O_RDONLY),
2930
)
3031
if err != nil {
31-
return false, err
32+
return false, 0, err
3233
}
3334
defer fi.UnloadContainer()
3435

3536
ii, err := ocitsif.ImageIndexFromFileImage(fi)
3637
if err != nil {
37-
return false, fmt.Errorf("while obtaining image index: %w", err)
38+
return false, 0, fmt.Errorf("while obtaining image index: %w", err)
3839
}
3940
ix, err := ii.IndexManifest()
4041
if err != nil {
41-
return false, fmt.Errorf("while obtaining index manifest: %w", err)
42+
return false, 0, fmt.Errorf("while obtaining index manifest: %w", err)
4243
}
4344

4445
// One image only.
4546
if len(ix.Manifests) != 1 {
46-
return false, fmt.Errorf("only single image data containers are supported, found %d images", len(ix.Manifests))
47+
return false, 0, fmt.Errorf("only single image data containers are supported, found %d images", len(ix.Manifests))
4748
}
4849
imageDigest := ix.Manifests[0].Digest
4950
img, err := ii.Image(imageDigest)
5051
if err != nil {
51-
return false, fmt.Errorf("while initializing image: %w", err)
52+
return false, 0, fmt.Errorf("while initializing image: %w", err)
5253
}
5354

5455
layers, err := img.Layers()
5556
if err != nil {
56-
return false, fmt.Errorf("while getting image layers: %w", err)
57+
return false, 0, fmt.Errorf("while getting image layers: %w", err)
5758
}
5859
if len(layers) < 1 {
59-
return false, fmt.Errorf("image has no layers")
60+
return false, 0, fmt.Errorf("image has no layers")
6061
}
6162
mt, err := layers[len(layers)-1].MediaType()
6263
if err != nil {
63-
return false, fmt.Errorf("while getting layer mediatype: %w", err)
64+
return false, 0, fmt.Errorf("while getting layer mediatype: %w", err)
6465
}
65-
return mt == Ext3LayerMediaType, nil
66+
// Not an overlay as last layer
67+
if mt != Ext3LayerMediaType {
68+
return false, 0, nil
69+
}
70+
71+
// Overlay as last layer, get offset
72+
ld, err := layers[len(layers)-1].Digest()
73+
if err != nil {
74+
return false, 0, fmt.Errorf("while getting layer digest: %w", err)
75+
}
76+
desc, err := fi.GetDescriptor(sif.WithOCIBlobDigest(ld))
77+
if err != nil {
78+
return false, 0, fmt.Errorf("while getting layer descriptor: %w", err)
79+
}
80+
return true, desc.Offset(), nil
6681
}
6782

6883
// AddOverlay adds the provided ext3 overlay file at overlayPath to the OCI-SIF

internal/pkg/ocisif/overlay_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ func TestHasOverlay(t *testing.T) {
6565
t.Fatal(err)
6666
}
6767

68-
got, err := HasOverlay(imgFile)
68+
got, _, err := HasOverlay(imgFile)
6969

7070
if got != tt.want {
7171
t.Errorf("Expected %v, got %v", tt.want, got)
@@ -132,7 +132,7 @@ func TestAddOverlay(t *testing.T) {
132132
t.Error("Expected error, but no error returned.")
133133
}
134134

135-
hasOverlay, err := HasOverlay(imgFile)
135+
hasOverlay, _, err := HasOverlay(imgFile)
136136
if err != nil {
137137
t.Fatal(err)
138138
}

internal/pkg/runtime/launcher/oci/launcher_linux.go

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import (
3838
imgutil "github.com/sylabs/singularity/v4/pkg/image"
3939
"github.com/sylabs/singularity/v4/pkg/ocibundle"
4040
"github.com/sylabs/singularity/v4/pkg/ocibundle/native"
41-
"github.com/sylabs/singularity/v4/pkg/ocibundle/ocisif"
41+
ocisifbundle "github.com/sylabs/singularity/v4/pkg/ocibundle/ocisif"
4242
sifbundle "github.com/sylabs/singularity/v4/pkg/ocibundle/sif"
4343
"github.com/sylabs/singularity/v4/pkg/ocibundle/tools"
4444
"github.com/sylabs/singularity/v4/pkg/sylog"
@@ -117,6 +117,10 @@ func NewLauncher(opts ...launcher.Option) (*Launcher, error) {
117117
if !lo.NoCompat || lo.WritableTmpfs {
118118
lo.WritableTmpfs = true
119119
}
120+
// Explicit writable (overlay) request means no WritableTmpfs
121+
if lo.Writable {
122+
lo.WritableTmpfs = false
123+
}
120124

121125
return &Launcher{
122126
cfg: lo,
@@ -133,10 +137,6 @@ func NewLauncher(opts ...launcher.Option) (*Launcher, error) {
133137
func checkOpts(lo launcher.Options) error {
134138
badOpt := []string{}
135139

136-
if lo.Writable {
137-
badOpt = append(badOpt, "Writable")
138-
}
139-
140140
if len(lo.FuseMount) > 0 {
141141
badOpt = append(badOpt, "FuseMount")
142142
}
@@ -252,10 +252,10 @@ func (l *Launcher) createSpec() (spec *specs.Spec, err error) {
252252
ms := minimalSpec()
253253
spec = &ms
254254

255-
// The OCI mode always wraps the rootfs in a tmpfs.
256-
// Whether we make it writable inside the container depends on a request for `--writable-tmpfs`.
257-
// Note that --writable-tmpfs is inferred by default in OCI mode. See NewLauncher().
258-
spec.Root.Readonly = !l.cfg.WritableTmpfs
255+
// Rootfs is writable if there is a writable tmpfs in place, or --writable
256+
// is requested with an overlay in the image. Note that --writable-tmpfs is
257+
// inferred by default in OCI mode. See NewLauncher().
258+
spec.Root.Readonly = !l.cfg.WritableTmpfs && !l.cfg.Writable
259259

260260
err = addNamespaces(spec, l.cfg.Namespaces)
261261
if err != nil {
@@ -722,9 +722,9 @@ func (l *Launcher) Exec(ctx context.Context, ep launcher.ExecParams) error {
722722
var b ocibundle.Bundle
723723
switch {
724724
case strings.HasPrefix(image, "oci-sif:"):
725-
b, err = ocisif.New(
726-
ocisif.OptBundlePath(bundleDir),
727-
ocisif.OptImageRef(image),
725+
b, err = ocisifbundle.New(
726+
ocisifbundle.OptBundlePath(bundleDir),
727+
ocisifbundle.OptImageRef(image),
728728
)
729729
case strings.HasPrefix(image, "sif:"):
730730
sylog.Infof("Running a non-OCI SIF in OCI mode. See user guide for compatibility information.")
@@ -832,11 +832,7 @@ func (l *Launcher) RunWrapped(ctx context.Context, containerID, bundlePath, pidF
832832
return err
833833
}
834834

835-
if len(l.cfg.OverlayPaths) > 0 {
836-
return WrapWithOverlays(ctx, runFunc, absBundle, l.cfg.OverlayPaths, l.cfg.AllowSUID)
837-
}
838-
839-
return WrapWithWritableTmpFs(ctx, runFunc, absBundle, l.cfg.AllowSUID)
835+
return l.WrapWithOverlays(ctx, runFunc, absBundle)
840836
}
841837

842838
// getCgroup will return a cgroup path and resources for the runtime to create.

0 commit comments

Comments
 (0)