Prechádzať zdrojové kódy

Merge pull request #41330 from BtbN/vol-pquota

Add size option to volumes on linux/unix via xfs pquota
Sebastiaan van Stijn 4 rokov pred
rodič
commit
ffd0861b8b

+ 1 - 1
daemon/graphdriver/graphtest/graphtest_unix.go

@@ -13,8 +13,8 @@ import (
 	"unsafe"
 
 	"github.com/docker/docker/daemon/graphdriver"
-	"github.com/docker/docker/daemon/graphdriver/quota"
 	"github.com/docker/docker/pkg/stringid"
+	"github.com/docker/docker/quota"
 	units "github.com/docker/go-units"
 	"golang.org/x/sys/unix"
 	"gotest.tools/v3/assert"

+ 1 - 1
daemon/graphdriver/overlay2/overlay.go

@@ -18,7 +18,6 @@ import (
 	"github.com/containerd/containerd/sys"
 	"github.com/docker/docker/daemon/graphdriver"
 	"github.com/docker/docker/daemon/graphdriver/overlayutils"
-	"github.com/docker/docker/daemon/graphdriver/quota"
 	"github.com/docker/docker/pkg/archive"
 	"github.com/docker/docker/pkg/chrootarchive"
 	"github.com/docker/docker/pkg/containerfs"
@@ -27,6 +26,7 @@ import (
 	"github.com/docker/docker/pkg/idtools"
 	"github.com/docker/docker/pkg/parsers"
 	"github.com/docker/docker/pkg/system"
+	"github.com/docker/docker/quota"
 	units "github.com/docker/go-units"
 	"github.com/moby/locker"
 	"github.com/moby/sys/mount"

+ 0 - 152
daemon/graphdriver/quota/projectquota_test.go

@@ -1,152 +0,0 @@
-// +build linux
-
-package quota // import "github.com/docker/docker/daemon/graphdriver/quota"
-
-import (
-	"io"
-	"io/ioutil"
-	"os"
-	"os/exec"
-	"path/filepath"
-	"testing"
-
-	"golang.org/x/sys/unix"
-	"gotest.tools/v3/assert"
-	is "gotest.tools/v3/assert/cmp"
-	"gotest.tools/v3/fs"
-)
-
-// 10MB
-const testQuotaSize = 10 * 1024 * 1024
-const imageSize = 64 * 1024 * 1024
-
-func TestBlockDev(t *testing.T) {
-	mkfs, err := exec.LookPath("mkfs.xfs")
-	if err != nil {
-		t.Skip("mkfs.xfs not found in PATH")
-	}
-
-	// create a sparse image
-	imageFile, err := ioutil.TempFile("", "xfs-image")
-	if err != nil {
-		t.Fatal(err)
-	}
-	imageFileName := imageFile.Name()
-	defer os.Remove(imageFileName)
-	if _, err = imageFile.Seek(imageSize-1, 0); err != nil {
-		t.Fatal(err)
-	}
-	if _, err = imageFile.Write([]byte{0}); err != nil {
-		t.Fatal(err)
-	}
-	if err = imageFile.Close(); err != nil {
-		t.Fatal(err)
-	}
-
-	// The reason for disabling these options is sometimes people run with a newer userspace
-	// than kernelspace
-	out, err := exec.Command(mkfs, "-m", "crc=0,finobt=0", imageFileName).CombinedOutput()
-	if len(out) > 0 {
-		t.Log(string(out))
-	}
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	t.Run("testBlockDevQuotaDisabled", wrapMountTest(imageFileName, false, testBlockDevQuotaDisabled))
-	t.Run("testBlockDevQuotaEnabled", wrapMountTest(imageFileName, true, testBlockDevQuotaEnabled))
-	t.Run("testSmallerThanQuota", wrapMountTest(imageFileName, true, wrapQuotaTest(testSmallerThanQuota)))
-	t.Run("testBiggerThanQuota", wrapMountTest(imageFileName, true, wrapQuotaTest(testBiggerThanQuota)))
-	t.Run("testRetrieveQuota", wrapMountTest(imageFileName, true, wrapQuotaTest(testRetrieveQuota)))
-}
-
-func wrapMountTest(imageFileName string, enableQuota bool, testFunc func(t *testing.T, mountPoint, backingFsDev string)) func(*testing.T) {
-	return func(t *testing.T) {
-		mountOptions := "loop"
-
-		if enableQuota {
-			mountOptions = mountOptions + ",prjquota"
-		}
-
-		mountPointDir := fs.NewDir(t, "xfs-mountPoint")
-		defer mountPointDir.Remove()
-		mountPoint := mountPointDir.Path()
-
-		out, err := exec.Command("mount", "-o", mountOptions, imageFileName, mountPoint).CombinedOutput()
-		if err != nil {
-			_, err := os.Stat("/proc/fs/xfs")
-			if os.IsNotExist(err) {
-				t.Skip("no /proc/fs/xfs")
-			}
-		}
-
-		assert.NilError(t, err, "mount failed: %s", out)
-
-		defer func() {
-			assert.NilError(t, unix.Unmount(mountPoint, 0))
-		}()
-
-		backingFsDev, err := makeBackingFsDev(mountPoint)
-		assert.NilError(t, err)
-
-		testFunc(t, mountPoint, backingFsDev)
-	}
-}
-
-func testBlockDevQuotaDisabled(t *testing.T, mountPoint, backingFsDev string) {
-	hasSupport, err := hasQuotaSupport(backingFsDev)
-	assert.NilError(t, err)
-	assert.Check(t, !hasSupport)
-}
-
-func testBlockDevQuotaEnabled(t *testing.T, mountPoint, backingFsDev string) {
-	hasSupport, err := hasQuotaSupport(backingFsDev)
-	assert.NilError(t, err)
-	assert.Check(t, hasSupport)
-}
-
-func wrapQuotaTest(testFunc func(t *testing.T, ctrl *Control, mountPoint, testDir, testSubDir string)) func(t *testing.T, mountPoint, backingFsDev string) {
-	return func(t *testing.T, mountPoint, backingFsDev string) {
-		testDir, err := ioutil.TempDir(mountPoint, "per-test")
-		assert.NilError(t, err)
-		defer os.RemoveAll(testDir)
-
-		ctrl, err := NewControl(testDir)
-		assert.NilError(t, err)
-
-		testSubDir, err := ioutil.TempDir(testDir, "quota-test")
-		assert.NilError(t, err)
-		testFunc(t, ctrl, mountPoint, testDir, testSubDir)
-	}
-
-}
-
-func testSmallerThanQuota(t *testing.T, ctrl *Control, homeDir, testDir, testSubDir string) {
-	assert.NilError(t, ctrl.SetQuota(testSubDir, Quota{testQuotaSize}))
-	smallerThanQuotaFile := filepath.Join(testSubDir, "smaller-than-quota")
-	assert.NilError(t, ioutil.WriteFile(smallerThanQuotaFile, make([]byte, testQuotaSize/2), 0644))
-	assert.NilError(t, os.Remove(smallerThanQuotaFile))
-}
-
-func testBiggerThanQuota(t *testing.T, ctrl *Control, homeDir, testDir, testSubDir string) {
-	// Make sure the quota is being enforced
-	// TODO: When we implement this under EXT4, we need to shed CAP_SYS_RESOURCE, otherwise
-	// we're able to violate quota without issue
-	assert.NilError(t, ctrl.SetQuota(testSubDir, Quota{testQuotaSize}))
-
-	biggerThanQuotaFile := filepath.Join(testSubDir, "bigger-than-quota")
-	err := ioutil.WriteFile(biggerThanQuotaFile, make([]byte, testQuotaSize+1), 0644)
-	assert.Assert(t, is.ErrorContains(err, ""))
-	if err == io.ErrShortWrite {
-		assert.NilError(t, os.Remove(biggerThanQuotaFile))
-	}
-}
-
-func testRetrieveQuota(t *testing.T, ctrl *Control, homeDir, testDir, testSubDir string) {
-	// Validate that we can retrieve quota
-	assert.NilError(t, ctrl.SetQuota(testSubDir, Quota{testQuotaSize}))
-
-	var q Quota
-	assert.NilError(t, ctrl.GetQuota(testSubDir, &q))
-	assert.Check(t, is.Equal(uint64(testQuotaSize), q.Size))
-}

+ 1 - 1
daemon/graphdriver/vfs/driver.go

@@ -6,12 +6,12 @@ import (
 	"path/filepath"
 
 	"github.com/docker/docker/daemon/graphdriver"
-	"github.com/docker/docker/daemon/graphdriver/quota"
 	"github.com/docker/docker/errdefs"
 	"github.com/docker/docker/pkg/containerfs"
 	"github.com/docker/docker/pkg/idtools"
 	"github.com/docker/docker/pkg/parsers"
 	"github.com/docker/docker/pkg/system"
+	"github.com/docker/docker/quota"
 	units "github.com/docker/go-units"
 	"github.com/opencontainers/selinux/go-selinux/label"
 	"github.com/pkg/errors"

+ 1 - 1
daemon/graphdriver/vfs/quota_linux.go

@@ -1,7 +1,7 @@
 package vfs // import "github.com/docker/docker/daemon/graphdriver/vfs"
 
 import (
-	"github.com/docker/docker/daemon/graphdriver/quota"
+	"github.com/docker/docker/quota"
 	"github.com/sirupsen/logrus"
 )
 

+ 1 - 1
daemon/graphdriver/vfs/quota_unsupported.go

@@ -2,7 +2,7 @@
 
 package vfs // import "github.com/docker/docker/daemon/graphdriver/vfs"
 
-import "github.com/docker/docker/daemon/graphdriver/quota"
+import "github.com/docker/docker/quota"
 
 type driverQuota struct {
 }

+ 1 - 1
daemon/graphdriver/quota/errors.go → quota/errors.go

@@ -1,4 +1,4 @@
-package quota // import "github.com/docker/docker/daemon/graphdriver/quota"
+package quota // import "github.com/docker/docker/quota"
 
 import "github.com/docker/docker/errdefs"
 

+ 83 - 18
daemon/graphdriver/quota/projectquota.go → quota/projectquota.go

@@ -9,7 +9,7 @@
 //       for both xfs/ext4 for kernel version >= v4.5
 //
 
-package quota // import "github.com/docker/docker/daemon/graphdriver/quota"
+package quota // import "github.com/docker/docker/quota"
 
 /*
 #include <stdlib.h>
@@ -55,6 +55,7 @@ import (
 	"io/ioutil"
 	"path"
 	"path/filepath"
+	"sync"
 	"unsafe"
 
 	"github.com/containerd/containerd/sys"
@@ -63,6 +64,33 @@ import (
 	"golang.org/x/sys/unix"
 )
 
+type pquotaState struct {
+	sync.Mutex
+	nextProjectID uint32
+}
+
+var pquotaStateInst *pquotaState
+var pquotaStateOnce sync.Once
+
+// getPquotaState - get global pquota state tracker instance
+func getPquotaState() *pquotaState {
+	pquotaStateOnce.Do(func() {
+		pquotaStateInst = &pquotaState{
+			nextProjectID: 1,
+		}
+	})
+	return pquotaStateInst
+}
+
+// registerBasePath - register a new base path and update nextProjectID
+func (state *pquotaState) updateMinProjID(minProjectID uint32) {
+	state.Lock()
+	defer state.Unlock()
+	if state.nextProjectID <= minProjectID {
+		state.nextProjectID = minProjectID + 1
+	}
+}
+
 // NewControl - initialize project quota support.
 // Test to make sure that quota can be set on a test dir and find
 // the first project id to be used for the next container create.
@@ -115,11 +143,11 @@ func NewControl(basePath string) (*Control, error) {
 	//
 	// Get project id of parent dir as minimal id to be used by driver
 	//
-	minProjectID, err := getProjectID(basePath)
+	baseProjectID, err := getProjectID(basePath)
 	if err != nil {
 		return nil, err
 	}
-	minProjectID++
+	minProjectID := baseProjectID + 1
 
 	//
 	// Test if filesystem supports project quotas by trying to set
@@ -134,19 +162,24 @@ func NewControl(basePath string) (*Control, error) {
 
 	q := Control{
 		backingFsBlockDev: backingFsBlockDev,
-		nextProjectID:     minProjectID + 1,
 		quotas:            make(map[string]uint32),
 	}
 
+	//
+	// update minimum project ID
+	//
+	state := getPquotaState()
+	state.updateMinProjID(minProjectID)
+
 	//
 	// get first project id to be used for next container
 	//
-	err = q.findNextProjectID(basePath)
+	err = q.findNextProjectID(basePath, baseProjectID)
 	if err != nil {
 		return nil, err
 	}
 
-	logrus.Debugf("NewControl(%s): nextProjectID = %d", basePath, q.nextProjectID)
+	logrus.Debugf("NewControl(%s): nextProjectID = %d", basePath, state.nextProjectID)
 	return &q, nil
 }
 
@@ -157,19 +190,24 @@ func (q *Control) SetQuota(targetPath string, quota Quota) error {
 	projectID, ok := q.quotas[targetPath]
 	q.RUnlock()
 	if !ok {
-		q.Lock()
-		projectID = q.nextProjectID
+		state := getPquotaState()
+		state.Lock()
+		projectID = state.nextProjectID
 
 		//
 		// assign project id to new container directory
 		//
 		err := setProjectID(targetPath, projectID)
 		if err != nil {
-			q.Unlock()
+			state.Unlock()
 			return err
 		}
+
+		state.nextProjectID++
+		state.Unlock()
+
+		q.Lock()
 		q.quotas[targetPath] = projectID
-		q.nextProjectID++
 		q.Unlock()
 	}
 
@@ -279,9 +317,25 @@ func setProjectID(targetPath string, projectID uint32) error {
 
 // findNextProjectID - find the next project id to be used for containers
 // by scanning driver home directory to find used project ids
-func (q *Control) findNextProjectID(home string) error {
-	q.Lock()
-	defer q.Unlock()
+func (q *Control) findNextProjectID(home string, baseID uint32) error {
+	state := getPquotaState()
+	state.Lock()
+	defer state.Unlock()
+
+	checkProjID := func(path string) (uint32, error) {
+		projid, err := getProjectID(path)
+		if err != nil {
+			return projid, err
+		}
+		if projid > 0 {
+			q.quotas[path] = projid
+		}
+		if state.nextProjectID <= projid {
+			state.nextProjectID = projid + 1
+		}
+		return projid, nil
+	}
+
 	files, err := ioutil.ReadDir(home)
 	if err != nil {
 		return errors.Errorf("read directory failed: %s", home)
@@ -291,15 +345,26 @@ func (q *Control) findNextProjectID(home string) error {
 			continue
 		}
 		path := filepath.Join(home, file.Name())
-		projid, err := getProjectID(path)
+		projid, err := checkProjID(path)
 		if err != nil {
 			return err
 		}
-		if projid > 0 {
-			q.quotas[path] = projid
+		if projid > 0 && projid != baseID {
+			continue
+		}
+		subfiles, err := ioutil.ReadDir(path)
+		if err != nil {
+			return errors.Errorf("read directory failed: %s", path)
 		}
-		if q.nextProjectID <= projid {
-			q.nextProjectID = projid + 1
+		for _, subfile := range subfiles {
+			if !subfile.IsDir() {
+				continue
+			}
+			subpath := filepath.Join(path, subfile.Name())
+			_, err := checkProjID(subpath)
+			if err != nil {
+				return err
+			}
 		}
 	}
 

+ 78 - 0
quota/projectquota_test.go

@@ -0,0 +1,78 @@
+// +build linux
+
+package quota // import "github.com/docker/docker/quota"
+
+import (
+	"io"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"gotest.tools/v3/assert"
+	is "gotest.tools/v3/assert/cmp"
+)
+
+// 10MB
+const testQuotaSize = 10 * 1024 * 1024
+
+func TestBlockDev(t *testing.T) {
+	if msg, ok := CanTestQuota(); !ok {
+		t.Skip(msg)
+	}
+
+	// get sparse xfs test image
+	imageFileName, err := PrepareQuotaTestImage(t)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer os.Remove(imageFileName)
+
+	t.Run("testBlockDevQuotaDisabled", WrapMountTest(imageFileName, false, testBlockDevQuotaDisabled))
+	t.Run("testBlockDevQuotaEnabled", WrapMountTest(imageFileName, true, testBlockDevQuotaEnabled))
+	t.Run("testSmallerThanQuota", WrapMountTest(imageFileName, true, WrapQuotaTest(testSmallerThanQuota)))
+	t.Run("testBiggerThanQuota", WrapMountTest(imageFileName, true, WrapQuotaTest(testBiggerThanQuota)))
+	t.Run("testRetrieveQuota", WrapMountTest(imageFileName, true, WrapQuotaTest(testRetrieveQuota)))
+}
+
+func testBlockDevQuotaDisabled(t *testing.T, mountPoint, backingFsDev, testDir string) {
+	hasSupport, err := hasQuotaSupport(backingFsDev)
+	assert.NilError(t, err)
+	assert.Check(t, !hasSupport)
+}
+
+func testBlockDevQuotaEnabled(t *testing.T, mountPoint, backingFsDev, testDir string) {
+	hasSupport, err := hasQuotaSupport(backingFsDev)
+	assert.NilError(t, err)
+	assert.Check(t, hasSupport)
+}
+
+func testSmallerThanQuota(t *testing.T, ctrl *Control, homeDir, testDir, testSubDir string) {
+	assert.NilError(t, ctrl.SetQuota(testSubDir, Quota{testQuotaSize}))
+	smallerThanQuotaFile := filepath.Join(testSubDir, "smaller-than-quota")
+	assert.NilError(t, ioutil.WriteFile(smallerThanQuotaFile, make([]byte, testQuotaSize/2), 0644))
+	assert.NilError(t, os.Remove(smallerThanQuotaFile))
+}
+
+func testBiggerThanQuota(t *testing.T, ctrl *Control, homeDir, testDir, testSubDir string) {
+	// Make sure the quota is being enforced
+	// TODO: When we implement this under EXT4, we need to shed CAP_SYS_RESOURCE, otherwise
+	// we're able to violate quota without issue
+	assert.NilError(t, ctrl.SetQuota(testSubDir, Quota{testQuotaSize}))
+
+	biggerThanQuotaFile := filepath.Join(testSubDir, "bigger-than-quota")
+	err := ioutil.WriteFile(biggerThanQuotaFile, make([]byte, testQuotaSize+1), 0644)
+	assert.Assert(t, is.ErrorContains(err, ""))
+	if err == io.ErrShortWrite {
+		assert.NilError(t, os.Remove(biggerThanQuotaFile))
+	}
+}
+
+func testRetrieveQuota(t *testing.T, ctrl *Control, homeDir, testDir, testSubDir string) {
+	// Validate that we can retrieve quota
+	assert.NilError(t, ctrl.SetQuota(testSubDir, Quota{testQuotaSize}))
+
+	var q Quota
+	assert.NilError(t, ctrl.GetQuota(testSubDir, &q))
+	assert.Check(t, is.Equal(uint64(testQuotaSize), q.Size))
+}

+ 2 - 2
daemon/graphdriver/quota/projectquota_unsupported.go → quota/projectquota_unsupported.go

@@ -1,6 +1,6 @@
-// +build linux,exclude_disk_quota linux,!cgo
+// +build linux,exclude_disk_quota linux,!cgo !linux
 
-package quota // import "github.com/docker/docker/daemon/graphdriver/quota"
+package quota // import "github.com/docker/docker/quota"
 
 func NewControl(basePath string) (*Control, error) {
 	return nil, ErrQuotaNotSupported

+ 122 - 0
quota/testhelpers.go

@@ -0,0 +1,122 @@
+// +build linux
+
+package quota // import "github.com/docker/docker/quota"
+
+import (
+	"io/ioutil"
+	"os"
+	"os/exec"
+	"testing"
+
+	"golang.org/x/sys/unix"
+	"gotest.tools/v3/assert"
+	"gotest.tools/v3/fs"
+)
+
+const imageSize = 64 * 1024 * 1024
+
+// CanTestQuota - checks if xfs prjquota can be tested
+// returns a reason if not
+func CanTestQuota() (string, bool) {
+	if os.Getuid() != 0 {
+		return "requires mounts", false
+	}
+	_, err := exec.LookPath("mkfs.xfs")
+	if err != nil {
+		return "mkfs.xfs not found in PATH", false
+	}
+	return "", true
+}
+
+// PrepareQuotaTestImage - prepares an xfs prjquota test image
+// returns the path the the image on success
+func PrepareQuotaTestImage(t *testing.T) (string, error) {
+	mkfs, err := exec.LookPath("mkfs.xfs")
+	if err != nil {
+		return "", err
+	}
+
+	// create a sparse image
+	imageFile, err := ioutil.TempFile("", "xfs-image")
+	if err != nil {
+		return "", err
+	}
+	imageFileName := imageFile.Name()
+	if _, err = imageFile.Seek(imageSize-1, 0); err != nil {
+		os.Remove(imageFileName)
+		return "", err
+	}
+	if _, err = imageFile.Write([]byte{0}); err != nil {
+		os.Remove(imageFileName)
+		return "", err
+	}
+	if err = imageFile.Close(); err != nil {
+		os.Remove(imageFileName)
+		return "", err
+	}
+
+	// The reason for disabling these options is sometimes people run with a newer userspace
+	// than kernelspace
+	out, err := exec.Command(mkfs, "-m", "crc=0,finobt=0", imageFileName).CombinedOutput()
+	if len(out) > 0 {
+		t.Log(string(out))
+	}
+	if err != nil {
+		os.Remove(imageFileName)
+		return "", err
+	}
+
+	return imageFileName, nil
+}
+
+// WrapMountTest - wraps a test function such that it has easy access to a mountPoint and testDir
+// with guaranteed prjquota or guaranteed no prjquota support.
+func WrapMountTest(imageFileName string, enableQuota bool, testFunc func(t *testing.T, mountPoint, backingFsDev, testDir string)) func(*testing.T) {
+	return func(t *testing.T) {
+		mountOptions := "loop"
+
+		if enableQuota {
+			mountOptions = mountOptions + ",prjquota"
+		}
+
+		mountPointDir := fs.NewDir(t, "xfs-mountPoint")
+		defer mountPointDir.Remove()
+		mountPoint := mountPointDir.Path()
+
+		out, err := exec.Command("mount", "-o", mountOptions, imageFileName, mountPoint).CombinedOutput()
+		if err != nil {
+			_, err := os.Stat("/proc/fs/xfs")
+			if os.IsNotExist(err) {
+				t.Skip("no /proc/fs/xfs")
+			}
+		}
+
+		assert.NilError(t, err, "mount failed: %s", out)
+
+		defer func() {
+			assert.NilError(t, unix.Unmount(mountPoint, 0))
+		}()
+
+		backingFsDev, err := makeBackingFsDev(mountPoint)
+		assert.NilError(t, err)
+
+		testDir, err := ioutil.TempDir(mountPoint, "per-test")
+		assert.NilError(t, err)
+		defer os.RemoveAll(testDir)
+
+		testFunc(t, mountPoint, backingFsDev, testDir)
+	}
+}
+
+// WrapQuotaTest - wraps a test function such that is has easy and guaranteed access to a quota Control
+// instance with a quota test dir under its control.
+func WrapQuotaTest(testFunc func(t *testing.T, ctrl *Control, mountPoint, testDir, testSubDir string)) func(t *testing.T, mountPoint, backingFsDev, testDir string) {
+	return func(t *testing.T, mountPoint, backingFsDev, testDir string) {
+		ctrl, err := NewControl(testDir)
+		assert.NilError(t, err)
+
+		testSubDir, err := ioutil.TempDir(testDir, "quota-test")
+		assert.NilError(t, err)
+		testFunc(t, ctrl, mountPoint, testDir, testSubDir)
+	}
+}

+ 1 - 4
daemon/graphdriver/quota/types.go → quota/types.go

@@ -1,6 +1,4 @@
-// +build linux
-
-package quota // import "github.com/docker/docker/daemon/graphdriver/quota"
+package quota // import "github.com/docker/docker/quota"
 
 import "sync"
 
@@ -14,6 +12,5 @@ type Quota struct {
 type Control struct {
 	backingFsBlockDev string
 	sync.RWMutex      // protect nextProjectID and quotas map
-	nextProjectID     uint32
 	quotas            map[string]uint32
 }

+ 17 - 3
volume/local/local.go

@@ -16,10 +16,12 @@ import (
 	"github.com/docker/docker/daemon/names"
 	"github.com/docker/docker/errdefs"
 	"github.com/docker/docker/pkg/idtools"
+	"github.com/docker/docker/quota"
 	"github.com/docker/docker/volume"
 	"github.com/moby/sys/mount"
 	"github.com/moby/sys/mountinfo"
 	"github.com/pkg/errors"
+	"github.com/sirupsen/logrus"
 )
 
 // VolumeDataPathName is the name of the directory where the volume data is stored.
@@ -66,6 +68,10 @@ func New(scope string, rootIdentity idtools.Identity) (*Root, error) {
 		return nil, err
 	}
 
+	if r.quotaCtl, err = quota.NewControl(rootDirectory); err != nil {
+		logrus.Debugf("No quota support for local volumes in %s: %v", rootDirectory, err)
+	}
+
 	for _, d := range dirs {
 		if !d.IsDir() {
 			continue
@@ -76,6 +82,7 @@ func New(scope string, rootIdentity idtools.Identity) (*Root, error) {
 			driverName: r.Name(),
 			name:       name,
 			path:       r.DataPath(name),
+			quotaCtl:   r.quotaCtl,
 		}
 		r.volumes[name] = v
 		optsFilePath := filepath.Join(rootDirectory, name, "opts.json")
@@ -105,6 +112,7 @@ type Root struct {
 	m            sync.Mutex
 	scope        string
 	path         string
+	quotaCtl     *quota.Control
 	volumes      map[string]*localVolume
 	rootIdentity idtools.Identity
 }
@@ -162,6 +170,7 @@ func (r *Root) Create(name string, opts map[string]string) (volume.Volume, error
 		driverName: r.Name(),
 		name:       name,
 		path:       path,
+		quotaCtl:   r.quotaCtl,
 	}
 
 	if len(opts) != 0 {
@@ -273,6 +282,8 @@ type localVolume struct {
 	opts *optsConfig
 	// active refcounts the active mounts
 	active activeMount
+	// reference to Root instances quotaCtl
+	quotaCtl *quota.Control
 }
 
 // Name returns the name of the given Volume.
@@ -300,7 +311,7 @@ func (v *localVolume) CachedPath() string {
 func (v *localVolume) Mount(id string) (string, error) {
 	v.m.Lock()
 	defer v.m.Unlock()
-	if v.opts != nil {
+	if v.needsMount() {
 		if !v.active.mounted {
 			if err := v.mount(); err != nil {
 				return "", errdefs.System(err)
@@ -309,6 +320,9 @@ func (v *localVolume) Mount(id string) (string, error) {
 		}
 		v.active.count++
 	}
+	if err := v.postMount(); err != nil {
+		return "", err
+	}
 	return v.path, nil
 }
 
@@ -322,7 +336,7 @@ func (v *localVolume) Unmount(id string) error {
 	// Essentially docker doesn't care if this fails, it will send an error, but
 	// ultimately there's nothing that can be done. If we don't decrement the count
 	// this volume can never be removed until a daemon restart occurs.
-	if v.opts != nil {
+	if v.needsMount() {
 		v.active.count--
 	}
 
@@ -334,7 +348,7 @@ func (v *localVolume) Unmount(id string) error {
 }
 
 func (v *localVolume) unmount() error {
-	if v.opts != nil {
+	if v.needsMount() {
 		if err := mount.Unmount(v.path); err != nil {
 			if mounted, mErr := mountinfo.Mounted(v.path); mounted || mErr != nil {
 				return errdefs.System(err)

+ 97 - 0
volume/local/local_linux_test.go

@@ -0,0 +1,97 @@
+// +build linux
+
+package local // import "github.com/docker/docker/volume/local"
+
+import (
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/docker/docker/pkg/idtools"
+	"github.com/docker/docker/quota"
+	"gotest.tools/v3/assert"
+	is "gotest.tools/v3/assert/cmp"
+)
+
+const quotaSize = 1024 * 1024
+const quotaSizeLiteral = "1M"
+
+func TestQuota(t *testing.T) {
+	if msg, ok := quota.CanTestQuota(); !ok {
+		t.Skip(msg)
+	}
+
+	// get sparse xfs test image
+	imageFileName, err := quota.PrepareQuotaTestImage(t)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer os.Remove(imageFileName)
+
+	t.Run("testVolWithQuota", quota.WrapMountTest(imageFileName, true, testVolWithQuota))
+	t.Run("testVolQuotaUnsupported", quota.WrapMountTest(imageFileName, false, testVolQuotaUnsupported))
+}
+
+func testVolWithQuota(t *testing.T, mountPoint, backingFsDev, testDir string) {
+	r, err := New(testDir, idtools.Identity{UID: os.Geteuid(), GID: os.Getegid()})
+	if err != nil {
+		t.Fatal(err)
+	}
+	assert.Assert(t, r.quotaCtl != nil)
+
+	vol, err := r.Create("testing", map[string]string{"size": quotaSizeLiteral})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	dir, err := vol.Mount("1234")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer func() {
+		if err := vol.Unmount("1234"); err != nil {
+			t.Fatal(err)
+		}
+	}()
+
+	testfile := filepath.Join(dir, "testfile")
+
+	// test writing file smaller than quota
+	assert.NilError(t, ioutil.WriteFile(testfile, make([]byte, quotaSize/2), 0644))
+	assert.NilError(t, os.Remove(testfile))
+
+	// test writing fiel larger than quota
+	err = ioutil.WriteFile(testfile, make([]byte, quotaSize+1), 0644)
+	assert.ErrorContains(t, err, "")
+	if _, err := os.Stat(testfile); err == nil {
+		assert.NilError(t, os.Remove(testfile))
+	}
+}
+
+func testVolQuotaUnsupported(t *testing.T, mountPoint, backingFsDev, testDir string) {
+	r, err := New(testDir, idtools.Identity{UID: os.Geteuid(), GID: os.Getegid()})
+	if err != nil {
+		t.Fatal(err)
+	}
+	assert.Assert(t, is.Nil(r.quotaCtl))
+
+	_, err = r.Create("testing", map[string]string{"size": quotaSizeLiteral})
+	assert.ErrorContains(t, err, "no quota support")
+
+	vol, err := r.Create("testing", nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// this could happen if someone moves volumes from storage with
+	// quota support to some place without
+	lv, ok := vol.(*localVolume)
+	assert.Assert(t, ok)
+	lv.opts = &optsConfig{
+		Quota: quota.Quota{Size: quotaSize},
+	}
+
+	_, err = vol.Mount("1234")
+	assert.ErrorContains(t, err, "no quota support")
+}

+ 55 - 9
volume/local/local_unix.go

@@ -15,6 +15,8 @@ import (
 	"time"
 
 	"github.com/docker/docker/errdefs"
+	"github.com/docker/docker/quota"
+	units "github.com/docker/go-units"
 	"github.com/moby/sys/mount"
 	"github.com/pkg/errors"
 )
@@ -26,10 +28,12 @@ var (
 		"type":   {}, // specify the filesystem type for mount, e.g. nfs
 		"o":      {}, // generic mount options
 		"device": {}, // device to mount from
+		"size":   {}, // quota size limit
 	}
-	mandatoryOpts = map[string]struct{}{
-		"device": {},
-		"type":   {},
+	mandatoryOpts = map[string][]string{
+		"device": []string{"type"},
+		"type":   []string{"device"},
+		"o":      []string{"device", "type"},
 	}
 )
 
@@ -37,10 +41,11 @@ type optsConfig struct {
 	MountType   string
 	MountOpts   string
 	MountDevice string
+	Quota       quota.Quota
 }
 
 func (o *optsConfig) String() string {
-	return fmt.Sprintf("type='%s' device='%s' o='%s'", o.MountType, o.MountDevice, o.MountOpts)
+	return fmt.Sprintf("type='%s' device='%s' o='%s' size='%d'", o.MountType, o.MountDevice, o.MountOpts, o.Quota.Size)
 }
 
 // scopedPath verifies that the path where the volume is located
@@ -63,15 +68,25 @@ func setOpts(v *localVolume, opts map[string]string) error {
 	if len(opts) == 0 {
 		return nil
 	}
-	if err := validateOpts(opts); err != nil {
+	err := validateOpts(opts)
+	if err != nil {
 		return err
 	}
-
 	v.opts = &optsConfig{
 		MountType:   opts["type"],
 		MountOpts:   opts["o"],
 		MountDevice: opts["device"],
 	}
+	if val, ok := opts["size"]; ok {
+		size, err := units.RAMInBytes(val)
+		if err != nil {
+			return err
+		}
+		if size > 0 && v.quotaCtl == nil {
+			return errdefs.InvalidParameter(errors.Errorf("quota size requested but no quota support"))
+		}
+		v.opts.Quota.Size = uint64(size)
+	}
 	return nil
 }
 
@@ -84,14 +99,28 @@ func validateOpts(opts map[string]string) error {
 			return errdefs.InvalidParameter(errors.Errorf("invalid option: %q", opt))
 		}
 	}
-	for opt := range mandatoryOpts {
-		if _, ok := opts[opt]; !ok {
-			return errdefs.InvalidParameter(errors.Errorf("missing required option: %q", opt))
+	for opt, reqopts := range mandatoryOpts {
+		if _, ok := opts[opt]; ok {
+			for _, reqopt := range reqopts {
+				if _, ok := opts[reqopt]; !ok {
+					return errdefs.InvalidParameter(errors.Errorf("missing required option: %q", reqopt))
+				}
+			}
 		}
 	}
 	return nil
 }
 
+func (v *localVolume) needsMount() bool {
+	if v.opts == nil {
+		return false
+	}
+	if v.opts.MountDevice != "" || v.opts.MountType != "" {
+		return true
+	}
+	return false
+}
+
 func (v *localVolume) mount() error {
 	if v.opts.MountDevice == "" {
 		return fmt.Errorf("missing device in volume options")
@@ -111,6 +140,23 @@ func (v *localVolume) mount() error {
 	return errors.Wrap(err, "failed to mount local volume")
 }
 
+func (v *localVolume) postMount() error {
+	if v.opts == nil {
+		return nil
+	}
+	if v.opts.Quota.Size > 0 {
+		if v.quotaCtl != nil {
+			err := v.quotaCtl.SetQuota(v.path, v.opts.Quota)
+			if err != nil {
+				return err
+			}
+		} else {
+			return fmt.Errorf("size quota requested for volume but no quota support")
+		}
+	}
+	return nil
+}
+
 func (v *localVolume) CreatedAt() (time.Time, error) {
 	fileInfo, err := os.Stat(v.path)
 	if err != nil {

+ 8 - 0
volume/local/local_windows.go

@@ -32,10 +32,18 @@ func setOpts(v *localVolume, opts map[string]string) error {
 	return nil
 }
 
+func (v *localVolume) needsMount() bool {
+	return false
+}
+
 func (v *localVolume) mount() error {
 	return nil
 }
 
+func (v *localVolume) postMount() error {
+	return nil
+}
+
 func (v *localVolume) CreatedAt() (time.Time, error) {
 	fileInfo, err := os.Stat(v.path)
 	if err != nil {