Repository: firecracker-microvm/firecracker-containerd
Pull request #147 (2019-03-22 22:14:50)

Implement usage for devmapper snapshotter
*Description of changes:*
This PR implements `Usage` for device mapper snapshotter.
It relies on the number of used blocks reported by `dmsetup status` command.

```
pool_device: 0 204800 thin-pool 0 13/25600 290/1600 - rw discard_passdown queue_if_no_space -
device3: 0 204800 thin 0 - <--- no file system
device2: 0 204800 thin 16128 204799 <---- just created with ext4 file system
device1: 0 204800 thin 18304 204799 <---- ext4 file system + 1 MB file
device1_snap: 0 204800 thin 20352 204799 <----- (parent device1) ext4 fs + 1 MB file from parent + 1 MB file extra (20352-18304)*512sectorSize/1024/1024=1MB
```

/ref https://github.com/containerd/containerd/pull/3022#issuecomment-475449586
/cc @cespo

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.



Patch:
diff --git a/snapshotter/devmapper/pool_device.go b/snapshotter/devmapper/pool_device.go
index dfe1309..8b0bee8 100644
--- a/snapshotter/devmapper/pool_device.go
+++ b/snapshotter/devmapper/pool_device.go
@@ -16,6 +16,7 @@ package devmapper
 import (
 	"context"
 	"path/filepath"
+	"strconv"
 
 	"github.com/containerd/containerd/log"
 	"github.com/hashicorp/go-multierror"
@@ -294,6 +295,29 @@ func (p *PoolDevice) IsActivated(deviceName string) bool {
 	return true
 }
 
+// GetUsage reports total size in bytes consumed by a thin-device.
+// It relies on the number of used blocks reported by 'dmsetup status'.
+// The output looks like:
+//  device2: 0 204800 thin 17280 204799
+// Where 17280 is the number of used sectors
+func (p *PoolDevice) GetUsage(deviceName string) (int64, error) {
+	status, err := dmsetup.Status(deviceName)
+	if err != nil {
+		return 0, errors.Wrapf(err, "can't get status for device %q", deviceName)
+	}
+
+	if len(status.Params) == 0 {
+		return 0, errors.Errorf("failed to get the number of used blocks, unexpected output from dmsetup status")
+	}
+
+	count, err := strconv.ParseInt(status.Params[0], 10, 64)
+	if err != nil {
+		return 0, errors.Wrapf(err, "failed to parse status params: %q", status.Params[0])
+	}
+
+	return count * dmsetup.SectorSize, nil
+}
+
 // RemoveDevice completely wipes out thin device from thin-pool and frees it's device ID
 func (p *PoolDevice) RemoveDevice(ctx context.Context, deviceName string) error {
 	info, err := p.metadata.GetDevice(ctx, deviceName)
diff --git a/snapshotter/devmapper/snapshotter.go b/snapshotter/devmapper/snapshotter.go
index 83c909a..88f68c8 100644
--- a/snapshotter/devmapper/snapshotter.go
+++ b/snapshotter/devmapper/snapshotter.go
@@ -121,11 +121,46 @@ func (s *Snapshotter) Update(ctx context.Context, info snapshots.Info, fieldpath
 	return info, err
 }
 
-// Usage not yet implemented
+// Usage returns the resource usage of an active or committed snapshot excluding the usage of parent snapshots.
 func (s *Snapshotter) Usage(ctx context.Context, key string) (snapshots.Usage, error) {
 	log.G(ctx).WithField("key", key).Debug("usage")
 
-	return snapshots.Usage{}, errors.New("usage not implemented")
+	var (
+		id    string
+		err   error
+		info  snapshots.Info
+		usage snapshots.Usage
+	)
+
+	err = s.withTransaction(ctx, false, func(ctx context.Context) error {
+		id, info, usage, err = storage.GetInfo(ctx, key)
+		if err != nil {
+			return err
+		}
+
+		if info.Kind == snapshots.KindActive {
+			deviceName := s.getDeviceName(id)
+			usage.Size, err = s.pool.GetUsage(deviceName)
+			if err != nil {
+				return err
+			}
+		}
+
+		if info.Parent != "" {
+			// GetInfo returns total number of bytes used by a snapshot (including parent).
+			// So subtract parent usage in order to get delta consumed by layer itself.
+			_, _, parentUsage, err := storage.GetInfo(ctx, info.Parent)
+			if err != nil {
+				return err
+			}
+
+			usage.Size -= parentUsage.Size
+		}
+
+		return err
+	})
+
+	return usage, err
 }
 
 // Mounts return the list of mounts for the active or view snapshot
@@ -186,7 +221,22 @@ func (s *Snapshotter) Commit(ctx context.Context, name, key string, opts ...snap
 	log.G(ctx).WithFields(logrus.Fields{"name": name, "key": key}).Debug("commit")
 
 	return s.withTransaction(ctx, true, func(ctx context.Context) error {
-		_, err := storage.CommitActive(ctx, key, name, snapshots.Usage{}, opts...)
+		id, _, _, err := storage.GetInfo(ctx, key)
+		if err != nil {
+			return err
+		}
+
+		deviceName := s.getDeviceName(id)
+		size, err := s.pool.GetUsage(deviceName)
+		if err != nil {
+			return err
+		}
+
+		usage := snapshots.Usage{
+			Size: size,
+		}
+
+		_, err = storage.CommitActive(ctx, key, name, usage, opts...)
 		return err
 	})
 }
diff --git a/snapshotter/pkg/dmsetup/dmsetup.go b/snapshotter/pkg/dmsetup/dmsetup.go
index 7c986ba..b707a21 100644
--- a/snapshotter/pkg/dmsetup/dmsetup.go
+++ b/snapshotter/pkg/dmsetup/dmsetup.go
@@ -258,6 +258,53 @@ func Version() (string, error) {
 	return dmsetup("version")
 }
 
+// DeviceStatus represents devmapper device status information
+type DeviceStatus struct {
+	Offset int64
+	Length int64
+	Target string
+	Params []string
+}
+
+// Status provides status information for devmapper device
+func Status(deviceName string) (*DeviceStatus, error) {
+	var (
+		err    error
+		status DeviceStatus
+	)
+
+	output, err := dmsetup("status", deviceName)
+	if err != nil {
+		return nil, err
+	}
+
+	// Status output format:
+	//  Offset (int64)
+	//  Length (int64)
+	//  Target type (string)
+	//  Params (Array of strings)
+	const MinParseCount = 4
+	parts := strings.Split(output, " ")
+	if len(parts) < MinParseCount {
+		return nil, errors.Errorf("failed to parse output: %q", output)
+	}
+
+	status.Offset, err = strconv.ParseInt(parts[0], 10, 64)
+	if err != nil {
+		return nil, errors.Wrapf(err, "failed to parse offset: %q", parts[0])
+	}
+
+	status.Length, err = strconv.ParseInt(parts[1], 10, 64)
+	if err != nil {
+		return nil, errors.Wrapf(err, "failed to parse length: %q", parts[1])
+	}
+
+	status.Target = parts[2]
+	status.Params = parts[3:]
+
+	return &status, nil
+}
+
 // GetFullDevicePath returns full path for the given device name (like "/dev/mapper/name")
 func GetFullDevicePath(deviceName string) string {
 	if strings.HasPrefix(deviceName, DevMapperDir) {
@@ -291,7 +338,7 @@ func dmsetup(args ...string) (string, error) {
 		return "", errors.Wrapf(err, "dmsetup %s\nerror: %s\n", strings.Join(args, " "), output)
 	}
 
-	output = strings.TrimSuffix(output, "\n")
+	output = strings.Trim(output, "\n")
 	output = strings.TrimSpace(output)
 
 	return output, nil


Test patch:
diff --git a/snapshotter/devmapper/pool_device_test.go b/snapshotter/devmapper/pool_device_test.go
index a5ea6f1..9d7acee 100644
--- a/snapshotter/devmapper/pool_device_test.go
+++ b/snapshotter/devmapper/pool_device_test.go
@@ -160,6 +160,10 @@ func testCreateThinDevice(t *testing.T, pool *PoolDevice) {
 	assert.NoError(t, err)
 
 	assert.NotEqual(t, deviceInfo1.DeviceID, deviceInfo2.DeviceID, "assigned device ids should be different")
+
+	usage, err := pool.GetUsage(thinDevice1)
+	assert.NoError(t, err)
+	assert.Zero(t, usage)
 }
 
 func testMakeFileSystem(t *testing.T, pool *PoolDevice) {
@@ -172,6 +176,10 @@ func testMakeFileSystem(t *testing.T, pool *PoolDevice) {
 
 	output, err := exec.Command("mkfs.ext4", args...).CombinedOutput()
 	require.NoErrorf(t, err, "failed to make filesystem on '%s': %s", thinDevice1, string(output))
+
+	usage, err := pool.GetUsage(thinDevice1)
+	assert.NoError(t, err)
+	assert.NotZero(t, usage)
 }
 
 func testCreateSnapshot(t *testing.T, pool *PoolDevice) {
diff --git a/snapshotter/devmapper/snapshotter_test.go b/snapshotter/devmapper/snapshotter_test.go
index ed3fa6e..2284832 100644
--- a/snapshotter/devmapper/snapshotter_test.go
+++ b/snapshotter/devmapper/snapshotter_test.go
@@ -16,13 +16,19 @@ package devmapper
 import (
 	"context"
 	"fmt"
+	"io/ioutil"
+	"os"
 	"testing"
 	"time"
 
+	"github.com/containerd/containerd/mount"
+	"github.com/containerd/containerd/namespaces"
 	"github.com/containerd/containerd/snapshots"
 	"github.com/containerd/containerd/snapshots/testsuite"
+	"github.com/containerd/continuity/fs/fstest"
 	"github.com/hashicorp/go-multierror"
 	"github.com/sirupsen/logrus"
+	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 
 	"github.com/firecracker-microvm/firecracker-containerd/internal"
@@ -34,7 +40,7 @@ func TestSnapshotterSuite(t *testing.T) {
 	internal.RequiresRoot(t)
 	logrus.SetLevel(logrus.DebugLevel)
 
-	testsuite.SnapshotterSuite(t, "devmapper", func(ctx context.Context, root string) (snapshots.Snapshotter, func() error, error) {
+	snapshotterFn := func(ctx context.Context, root string) (snapshots.Snapshotter, func() error, error) {
 		// Create loopback devices for each test case
 		_, loopDataDevice := createLoopbackDevice(t, root)
 		_, loopMetaDevice := createLoopbackDevice(t, root)
@@ -67,5 +73,63 @@ func TestSnapshotterSuite(t *testing.T) {
 		snap.cleanupFn = append([]closeFunc{removePool}, snap.cleanupFn...)
 
 		return snap, snap.Close, nil
+	}
+
+	testsuite.SnapshotterSuite(t, "devmapper", snapshotterFn)
+
+	ctx := context.Background()
+	ctx = namespaces.WithNamespace(ctx, "testsuite")
+
+	t.Run("DevMapperUsage", func(t *testing.T) {
+		tempDir, err := ioutil.TempDir("", "snapshot-suite-usage")
+		require.NoError(t, err)
+		defer os.RemoveAll(tempDir)
+
+		snapshotter, closer, err := snapshotterFn(ctx, tempDir)
+		require.NoError(t, err)
+		defer closer()
+
+		testUsage(t, snapshotter)
 	})
 }
+
+// testUsage tests devmapper's Usage implementation. This is an approximate test as it's hard to
+// predict how many blocks will be consumed under different conditions and parameters.
+func testUsage(t *testing.T, snapshotter snapshots.Snapshotter) {
+	ctx := context.Background()
+
+	// Create empty base layer
+	_, err := snapshotter.Prepare(ctx, "prepare-1", "")
+	require.NoError(t, err)
+
+	emptyLayerUsage, err := snapshotter.Usage(ctx, "prepare-1")
+	assert.NoError(t, err)
+
+	// Should be > 0 as just written file system also consumes blocks
+	assert.NotZero(t, emptyLayerUsage.Size)
+
+	err = snapshotter.Commit(ctx, "layer-1", "prepare-1")
+	require.NoError(t, err)
+
+	// Create child layer with 1MB file
+
+	var (
+		sizeBytes   int64 = 1048576 // 1MB
+		baseApplier       = fstest.Apply(fstest.CreateRandomFile("/a", 12345679, sizeBytes, 0777))
+	)
+
+	mounts, err := snapshotter.Prepare(ctx, "prepare-2", "layer-1")
+	require.NoError(t, err)
+
+	err = mount.WithTempMount(ctx, mounts, baseApplier.Apply)
+	require.NoError(t, err)
+
+	err = snapshotter.Commit(ctx, "layer-2", "prepare-2")
+	require.NoError(t, err)
+
+	layer2Usage, err := snapshotter.Usage(ctx, "layer-2")
+	require.NoError(t, err)
+
+	// Should be at least 1 MB + fs metadata
+	assert.InDelta(t, sizeBytes, layer2Usage.Size, 256*dmsetup.SectorSize)
+}
diff --git a/snapshotter/pkg/dmsetup/dmsetup_test.go b/snapshotter/pkg/dmsetup/dmsetup_test.go
index f3c2c95..ec4d4d0 100644
--- a/snapshotter/pkg/dmsetup/dmsetup_test.go
+++ b/snapshotter/pkg/dmsetup/dmsetup_test.go
@@ -20,11 +20,11 @@ import (
 	"testing"
 
 	"github.com/docker/go-units"
-	"github.com/firecracker-microvm/firecracker-containerd/internal"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 	"golang.org/x/sys/unix"
 
+	"github.com/firecracker-microvm/firecracker-containerd/internal"
 	"github.com/firecracker-microvm/firecracker-containerd/snapshotter/pkg/losetup"
 )
 
@@ -78,6 +78,7 @@ func TestDMSetup(t *testing.T) {
 	t.Run("DeleteSnapshot", testDeleteSnapshot)
 
 	t.Run("ActivateDevice", testActivateDevice)
+	t.Run("DeviceStatus", testDeviceStatus)
 	t.Run("SuspendResumeDevice", testSuspendResumeDevice)
 	t.Run("RemoveDevice", testRemoveDevice)
 
@@ -134,6 +135,16 @@ func testActivateDevice(t *testing.T) {
 	assert.True(t, info.TableLive)
 }
 
+func testDeviceStatus(t *testing.T) {
+	status, err := Status(testDeviceName)
+	require.NoError(t, err)
+
+	assert.EqualValues(t, 0, status.Offset)
+	assert.EqualValues(t, 2, status.Length)
+	assert.Equal(t, "thin", status.Target)
+	assert.EqualValues(t, status.Params, []string{"0", "-"})
+}
+
 func testSuspendResumeDevice(t *testing.T) {
 	err := SuspendDevice(testDeviceName)
 	assert.NoError(t, err)