Add tests to project quotas and detection mechanism
This adds a mechanism (read-only) to check for project quota support
in a standard way. This mechanism is leveraged by the tests, which
test for the following:
1. Can we get a quota controller?
2. Can we set the quota for a particular directory?
3. Is the quota being over-enforced?
4. Is the quota being under-enforced?
5. Can we retrieve the quota?
Signed-off-by: Sargun Dhillon <sargun@sargun.me>
diff --git a/daemon/graphdriver/quota/projectquota.go b/daemon/graphdriver/quota/projectquota.go
index 0e70515..84e391a 100644
--- a/daemon/graphdriver/quota/projectquota.go
+++ b/daemon/graphdriver/quota/projectquota.go
@@ -47,6 +47,8 @@
#ifndef Q_XGETPQUOTA
#define Q_XGETPQUOTA QCMD(Q_XGETQUOTA, PRJQUOTA)
#endif
+
+const int Q_XGETQSTAT_PRJQUOTA = QCMD(Q_XGETQSTAT, PRJQUOTA);
*/
import "C"
import (
@@ -56,10 +58,15 @@
"path/filepath"
"unsafe"
+ "errors"
+
"github.com/sirupsen/logrus"
"golang.org/x/sys/unix"
)
+// ErrQuotaNotSupported indicates if were found the FS does not have projects quotas available
+var ErrQuotaNotSupported = errors.New("Filesystem does not support or has not enabled quotas")
+
// Quota limit params - currently we only control blocks hard limit
type Quota struct {
Size uint64
@@ -97,6 +104,24 @@
//
func NewControl(basePath string) (*Control, error) {
//
+ // create backing filesystem device node
+ //
+ backingFsBlockDev, err := makeBackingFsDev(basePath)
+ if err != nil {
+ return nil, err
+ }
+
+ // check if we can call quotactl with project quotas
+ // as a mechanism to determine (early) if we have support
+ hasQuotaSupport, err := hasQuotaSupport(backingFsBlockDev)
+ if err != nil {
+ return nil, err
+ }
+ if !hasQuotaSupport {
+ return nil, ErrQuotaNotSupported
+ }
+
+ //
// Get project id of parent dir as minimal id to be used by driver
//
minProjectID, err := getProjectID(basePath)
@@ -106,14 +131,6 @@
minProjectID++
//
- // create backing filesystem device node
- //
- backingFsBlockDev, err := makeBackingFsDev(basePath)
- if err != nil {
- return nil, err
- }
-
- //
// Test if filesystem supports project quotas by trying to set
// a quota on the first available project id
//
@@ -335,3 +352,23 @@
return backingFsBlockDev, nil
}
+
+func hasQuotaSupport(backingFsBlockDev string) (bool, error) {
+ var cs = C.CString(backingFsBlockDev)
+ defer free(cs)
+ var qstat C.fs_quota_stat_t
+
+ _, _, errno := unix.Syscall6(unix.SYS_QUOTACTL, uintptr(C.Q_XGETQSTAT_PRJQUOTA), uintptr(unsafe.Pointer(cs)), 0, uintptr(unsafe.Pointer(&qstat)), 0, 0)
+ if errno == 0 && qstat.qs_flags&C.FS_QUOTA_PDQ_ENFD > 0 && qstat.qs_flags&C.FS_QUOTA_PDQ_ACCT > 0 {
+ return true, nil
+ }
+
+ switch errno {
+ // These are the known fatal errors, consider all other errors (ENOTTY, etc.. not supporting quota)
+ case unix.EFAULT, unix.ENOENT, unix.ENOTBLK, unix.EPERM:
+ default:
+ return false, nil
+ }
+
+ return false, errno
+}
diff --git a/daemon/graphdriver/quota/projectquota_test.go b/daemon/graphdriver/quota/projectquota_test.go
new file mode 100644
index 0000000..2b47a58
--- /dev/null
+++ b/daemon/graphdriver/quota/projectquota_test.go
@@ -0,0 +1,161 @@
+// +build linux
+
+package quota
+
+import (
+ "io"
+ "io/ioutil"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/sys/unix"
+)
+
+// 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.Fatal("mkfs.xfs not installed")
+ }
+
+ // 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)
+ }
+
+ runTest(t, "testBlockDevQuotaDisabled", wrapMountTest(imageFileName, false, testBlockDevQuotaDisabled))
+ runTest(t, "testBlockDevQuotaEnabled", wrapMountTest(imageFileName, true, testBlockDevQuotaEnabled))
+ runTest(t, "testSmallerThanQuota", wrapMountTest(imageFileName, true, wrapQuotaTest(testSmallerThanQuota)))
+ runTest(t, "testBiggerThanQuota", wrapMountTest(imageFileName, true, wrapQuotaTest(testBiggerThanQuota)))
+ runTest(t, "testRetrieveQuota", wrapMountTest(imageFileName, true, wrapQuotaTest(testRetrieveQuota)))
+}
+
+func runTest(t *testing.T, testName string, testFunc func(*testing.T)) {
+ if success := t.Run(testName, testFunc); !success {
+ out, _ := exec.Command("dmesg").CombinedOutput()
+ t.Log(string(out))
+ }
+}
+
+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"
+ }
+
+ // create a mountPoint
+ mountPoint, err := ioutil.TempDir("", "xfs-mountPoint")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer os.RemoveAll(mountPoint)
+
+ out, err := exec.Command("mount", "-o", mountOptions, imageFileName, mountPoint).CombinedOutput()
+ if len(out) > 0 {
+ t.Log(string(out))
+ }
+ if err != nil {
+ t.Fatal("mount failed")
+ }
+
+ defer func() {
+ if err := unix.Unmount(mountPoint, 0); err != nil {
+ t.Fatal(err)
+ }
+ }()
+
+ backingFsDev, err := makeBackingFsDev(mountPoint)
+ require.NoError(t, err)
+
+ testFunc(t, mountPoint, backingFsDev)
+ }
+}
+
+func testBlockDevQuotaDisabled(t *testing.T, mountPoint, backingFsDev string) {
+ hasSupport, err := hasQuotaSupport(backingFsDev)
+ require.NoError(t, err)
+ assert.False(t, hasSupport)
+}
+
+func testBlockDevQuotaEnabled(t *testing.T, mountPoint, backingFsDev string) {
+ hasSupport, err := hasQuotaSupport(backingFsDev)
+ require.NoError(t, err)
+ assert.True(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")
+ require.NoError(t, err)
+ defer os.RemoveAll(testDir)
+
+ ctrl, err := NewControl(testDir)
+ require.NoError(t, err)
+
+ testSubDir, err := ioutil.TempDir(testDir, "quota-test")
+ require.NoError(t, err)
+ testFunc(t, ctrl, mountPoint, testDir, testSubDir)
+ }
+
+}
+
+func testSmallerThanQuota(t *testing.T, ctrl *Control, homeDir, testDir, testSubDir string) {
+ require.NoError(t, ctrl.SetQuota(testSubDir, Quota{testQuotaSize}))
+ smallerThanQuotaFile := filepath.Join(testSubDir, "smaller-than-quota")
+ require.NoError(t, ioutil.WriteFile(smallerThanQuotaFile, make([]byte, testQuotaSize/2), 0644))
+ require.NoError(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
+ require.NoError(t, ctrl.SetQuota(testSubDir, Quota{testQuotaSize}))
+
+ biggerThanQuotaFile := filepath.Join(testSubDir, "bigger-than-quota")
+ err := ioutil.WriteFile(biggerThanQuotaFile, make([]byte, testQuotaSize+1), 0644)
+ require.Error(t, err)
+ if err == io.ErrShortWrite {
+ require.NoError(t, os.Remove(biggerThanQuotaFile))
+ }
+}
+
+func testRetrieveQuota(t *testing.T, ctrl *Control, homeDir, testDir, testSubDir string) {
+ // Validate that we can retrieve quota
+ require.NoError(t, ctrl.SetQuota(testSubDir, Quota{testQuotaSize}))
+
+ var q Quota
+ require.NoError(t, ctrl.GetQuota(testSubDir, &q))
+ assert.EqualValues(t, testQuotaSize, q.Size)
+}