diff --git a/core/snapshots/snapshotter.go b/core/snapshots/snapshotter.go index 5cae7aae37..cb94e12110 100644 --- a/core/snapshots/snapshotter.go +++ b/core/snapshots/snapshotter.go @@ -39,6 +39,15 @@ const ( LabelSnapshotUIDMapping = "containerd.io/snapshot/uidmapping" // LabelSnapshotGIDMapping is the label used for GID mappings LabelSnapshotGIDMapping = "containerd.io/snapshot/gidmapping" + + // LabelSnapshotMaxSize is a hint to the snapshotter that the active + // snapshot's filesystem should be limited to the given size, in bytes + // (decimal int64 as a string). Snapshotters that back an active + // snapshot with a block image or support filesystem quotas should + // honor this value; those that cannot enforce a size may ignore it. + // Ignoring is not a failure — callers that require enforcement must + // pick a snapshotter that supports it. + LabelSnapshotMaxSize = "containerd.io/snapshot/max-size" ) // Kind identifies the kind of snapshot. diff --git a/plugins/snapshots/erofs/erofs.go b/plugins/snapshots/erofs/erofs.go index 586d16583c..2f038706f2 100644 --- a/plugins/snapshots/erofs/erofs.go +++ b/plugins/snapshots/erofs/erofs.go @@ -22,6 +22,7 @@ import ( "os" "path/filepath" "runtime" + "strconv" "github.com/containerd/continuity/fs" "github.com/containerd/errdefs" @@ -207,6 +208,25 @@ func (s *snapshotter) writablePath(id string) string { return filepath.Join(s.root, "snapshots", id, "rwlayer.img") } +// writableSize returns the size (in bytes) to allocate for this active +// snapshot's writable block image. If the caller set the +// LabelSnapshotMaxSize label, that value is used; otherwise the +// snapshotter's configured default is used. Only consulted in block mode. +func (s *snapshotter) writableSize(info snapshots.Info) int64 { + v, ok := info.Labels[snapshots.LabelSnapshotMaxSize] + if !ok { + return s.defaultWritable + } + size, err := strconv.ParseInt(v, 10, 64) + if err != nil || size <= 0 { + log.L.WithField("label", snapshots.LabelSnapshotMaxSize). + WithField("value", v). + Warn("invalid max-size label, falling back to default") + return s.defaultWritable + } + return size +} + // A committed layer blob generated by the EROFS differ func (s *snapshotter) layerBlobPath(id string) string { return filepath.Join(s.root, "snapshots", id, "layer.erofs") @@ -356,8 +376,7 @@ func (s *snapshotter) mounts(snap storage.Snapshot, info snapshots.Info) ([]moun Type: "mkfs/ext4", Options: []string{ "X-containerd.mkfs.fs=ext4", - // TODO: Get size from snapshot labels - fmt.Sprintf("X-containerd.mkfs.size=%d", s.defaultWritable), + fmt.Sprintf("X-containerd.mkfs.size=%d", s.writableSize(info)), // TODO: Add UUID roFlag, "loop", @@ -398,8 +417,7 @@ func (s *snapshotter) mounts(snap storage.Snapshot, info snapshots.Info) ([]moun Type: "mkfs/ext4", Options: []string{ "X-containerd.mkfs.fs=ext4", - // TODO: Get size from snapshot labels - fmt.Sprintf("X-containerd.mkfs.size=%d", s.defaultWritable), + fmt.Sprintf("X-containerd.mkfs.size=%d", s.writableSize(info)), // TODO: Add UUID "rw", "loop", diff --git a/plugins/snapshots/erofs/erofs_linux_test.go b/plugins/snapshots/erofs/erofs_linux_test.go index 07e1600890..3c182d800d 100644 --- a/plugins/snapshots/erofs/erofs_linux_test.go +++ b/plugins/snapshots/erofs/erofs_linux_test.go @@ -113,6 +113,50 @@ func TestErofsWithQuota(t *testing.T) { testsuite.SnapshotterSuite(t, "erofs", newSnapshotter(t, WithDefaultSize(16*1024*1024))) } +// TestWritableSize exercises the LabelSnapshotMaxSize override that the +// block-mode mkfs path passes to X-containerd.mkfs.size. Covers the +// happy path (label overrides default), fallback cases (missing, empty, +// malformed, non-positive), and that a valid label wins over a non-zero +// configured default. +func TestWritableSize(t *testing.T) { + const defaultSize = int64(16 * 1024 * 1024) + s := &snapshotter{defaultWritable: defaultSize} + + for _, tc := range []struct { + name string + labels map[string]string + want int64 + }{ + {"unset", nil, defaultSize}, + {"empty-map", map[string]string{}, defaultSize}, + {"valid-overrides-default", map[string]string{snapshots.LabelSnapshotMaxSize: "268435456"}, 268435456}, + {"empty-value-falls-back", map[string]string{snapshots.LabelSnapshotMaxSize: ""}, defaultSize}, + {"non-numeric-falls-back", map[string]string{snapshots.LabelSnapshotMaxSize: "100MB"}, defaultSize}, + {"zero-falls-back", map[string]string{snapshots.LabelSnapshotMaxSize: "0"}, defaultSize}, + {"negative-falls-back", map[string]string{snapshots.LabelSnapshotMaxSize: "-1"}, defaultSize}, + } { + t.Run(tc.name, func(t *testing.T) { + got := s.writableSize(snapshots.Info{Labels: tc.labels}) + assert.Equal(t, tc.want, got) + }) + } + + // Also verify behaviour when the snapshotter has no configured default: + // an unset/invalid label yields 0 (caller treats as "no size"), a valid + // label is respected. + t.Run("no-default-unset", func(t *testing.T) { + z := &snapshotter{defaultWritable: 0} + assert.Equal(t, int64(0), z.writableSize(snapshots.Info{})) + }) + t.Run("no-default-with-label", func(t *testing.T) { + z := &snapshotter{defaultWritable: 0} + got := z.writableSize(snapshots.Info{Labels: map[string]string{ + snapshots.LabelSnapshotMaxSize: "1048576", + }}) + assert.Equal(t, int64(1048576), got) + }) +} + func TestErofsFsverity(t *testing.T) { testutil.RequiresRoot(t) ctx := context.Background()