Repository: ceph/go-ceph
Pull request #366 (2020-09-02 18:32:07)

cephfs admin: add subvolume info function
Fixes: #363

Add the SubVolumeInfo function, returning SubVolumeInfo type. There are two customization worth noting, the TimeStamp type is added to deal with the difference between Go JSON defaults for time.Time and ceph's, and the use of a placeholder type to unmarshal the value of the "bytes_quota" field, which can either be a string "infinite" or a numeric value. This maps well to the previously created interface, but interfaces can't unmarshal themselves. The placeholder type helps with this conversion. See commit msg for more details.

## Checklist
- [x] Added tests for features and functional changes
- [x] Public functions and types are documented
- [x] Standard formatting is applied to Go code



Patch:
diff --git a/cephfs/admin/bytecount.go b/cephfs/admin/bytecount.go
index a1b60cb..f490bc3 100644
--- a/cephfs/admin/bytecount.go
+++ b/cephfs/admin/bytecount.go
@@ -2,6 +2,11 @@
 
 package admin
 
+import (
+	"encoding/json"
+	"fmt"
+)
+
 // ByteCount represents the size of a volume in bytes.
 type ByteCount uint64
 
@@ -13,25 +18,51 @@ const (
 	tebiByte           = 1024 * gibiByte
 )
 
-// newSizeValue returns a size value as a string, as needed by the subvolume
+// resizeValue returns a size value as a string, as needed by the subvolume
 // resize command json.
-func (bc ByteCount) newSizeValue() string {
+func (bc ByteCount) resizeValue() string {
 	return uint64String(uint64(bc))
 }
 
-// NewSize interface values can be used to change the size of a volume.
-type NewSize interface {
-	newSizeValue() string
+// QuotaSize interface values can be used to change the size of a volume.
+type QuotaSize interface {
+	resizeValue() string
 }
 
-// specialSize is a custom non-numeric new size value.
+// specialSize is a custom non-numeric quota size value.
 type specialSize string
 
-// newSizeValue for a specialSize returns the literal string.
-func (s specialSize) newSizeValue() string {
+// resizeValue for a specialSize returns the original string value.
+func (s specialSize) resizeValue() string {
 	return string(s)
 }
 
-// Infinite is a special NewSize value that can be used to clear size limits on
-// a subvolume.
+// Infinite is a special QuotaSize value that can be used to clear size limits
+// on a subvolume.
 const Infinite = specialSize("infinite")
+
+// quotaSizePlaceholder types are helpful to extract QuotaSize typed values
+// from JSON responses.
+type quotaSizePlaceholder struct {
+	Value QuotaSize
+}
+
+func (p *quotaSizePlaceholder) UnmarshalJSON(b []byte) error {
+	var val interface{}
+	if err := json.Unmarshal(b, &val); err != nil {
+		return err
+	}
+	switch v := val.(type) {
+	case string:
+		if v == string(Infinite) {
+			p.Value = Infinite
+		} else {
+			return fmt.Errorf("quota size: invalid string value: %q", v)
+		}
+	case float64:
+		p.Value = ByteCount(v)
+	default:
+		return fmt.Errorf("quota size: invalid type, string or number required: %v (%T)", val, val)
+	}
+	return nil
+}
diff --git a/cephfs/admin/subvolume.go b/cephfs/admin/subvolume.go
index a0d02f7..d17ce32 100644
--- a/cephfs/admin/subvolume.go
+++ b/cephfs/admin/subvolume.go
@@ -125,7 +125,7 @@ type SubVolumeResizeResult struct {
 //  ceph fs subvolume resize <volume> --group-name=<group> <name> ...
 func (fsa *FSAdmin) ResizeSubVolume(
 	volume, group, name string,
-	newSize NewSize, noShrink bool) (*SubVolumeResizeResult, error) {
+	newSize QuotaSize, noShrink bool) (*SubVolumeResizeResult, error) {
 
 	f := &subVolumeResizeFields{
 		Prefix:    "fs subvolume resize",
@@ -133,7 +133,7 @@ func (fsa *FSAdmin) ResizeSubVolume(
 		VolName:   volume,
 		GroupName: group,
 		SubName:   name,
-		NewSize:   newSize.newSizeValue(),
+		NewSize:   newSize.resizeValue(),
 		NoShrink:  noShrink,
 	}
 	var result []*SubVolumeResizeResult
@@ -160,3 +160,54 @@ func (fsa *FSAdmin) SubVolumePath(volume, group, name string) (string, error) {
 	}
 	return extractPathResponse(fsa.marshalMgrCommand(m))
 }
+
+// SubVolumeInfo reports various informational values about a subvolume.
+type SubVolumeInfo struct {
+	Type          string    `json:"type"`
+	Path          string    `json:"path"`
+	Uid           int       `json:"uid"`
+	Gid           int       `json:"gid"`
+	Mode          int       `json:"mode"`
+	BytesPercent  string    `json:"bytes_pcent"`
+	BytesUsed     ByteCount `json:"bytes_used"`
+	BytesQuota    QuotaSize `json:"-"`
+	DataPool      string    `json:"data_pool"`
+	PoolNamespace string    `json:"pool_namespace"`
+	Atime         TimeStamp `json:"atime"`
+	Mtime         TimeStamp `json:"mtime"`
+	Ctime         TimeStamp `json:"ctime"`
+	CreatedAt     TimeStamp `json:"created_at"`
+}
+
+type subVolumeInfoWrapper struct {
+	SubVolumeInfo
+	VBytesQuota *quotaSizePlaceholder `json:"bytes_quota"`
+}
+
+func parseSubVolumeInfo(r []byte, s string, err error) (*SubVolumeInfo, error) {
+	var info subVolumeInfoWrapper
+	if err := unmarshalResponseJSON(r, s, err, &info); err != nil {
+		return nil, err
+	}
+	if info.VBytesQuota != nil {
+		info.BytesQuota = info.VBytesQuota.Value
+	}
+	return &info.SubVolumeInfo, nil
+}
+
+// SubVolumeInfo returns information about the specified subvolume.
+//
+// Similar To:
+//  ceph fs subvolume info <volume> --group-name=<group> <name>
+func (fsa *FSAdmin) SubVolumeInfo(volume, group, name string) (*SubVolumeInfo, error) {
+	m := map[string]string{
+		"prefix":   "fs subvolume info",
+		"vol_name": volume,
+		"sub_name": name,
+		"format":   "json",
+	}
+	if group != NoGroup {
+		m["group_name"] = group
+	}
+	return parseSubVolumeInfo(fsa.marshalMgrCommand(m))
+}
diff --git a/cephfs/admin/timestamp.go b/cephfs/admin/timestamp.go
new file mode 100644
index 0000000..424fab6
--- /dev/null
+++ b/cephfs/admin/timestamp.go
@@ -0,0 +1,39 @@
+// +build !luminous,!mimic
+
+package admin
+
+import (
+	"encoding/json"
+	"time"
+)
+
+// golang's date parsing approach is rather bizarre
+var cephTSLayout = "2006-01-02 15:04:05"
+
+// TimeStamp abstracts some of the details about date+time stamps
+// returned by ceph via JSON.
+type TimeStamp struct {
+	time.Time
+}
+
+// String returns a string representing the date+time as presented
+// by ceph.
+func (ts TimeStamp) String() string {
+	return ts.Format(cephTSLayout)
+}
+
+// UnmarshalJSON implements the json Unmarshaler interface.
+func (ts *TimeStamp) UnmarshalJSON(b []byte) error {
+	var raw string
+	if err := json.Unmarshal(b, &raw); err != nil {
+		return err
+	}
+	// AFAICT, ceph always returns the time in UTC so Parse, as opposed to
+	// ParseInLocation, is appropriate here.
+	t, err := time.Parse(cephTSLayout, raw)
+	if err != nil {
+		return err
+	}
+	*ts = TimeStamp{t}
+	return nil
+}


Test patch:
diff --git a/cephfs/admin/subvolume_test.go b/cephfs/admin/subvolume_test.go
index 57bed10..f78f1da 100644
--- a/cephfs/admin/subvolume_test.go
+++ b/cephfs/admin/subvolume_test.go
@@ -3,6 +3,7 @@
 package admin
 
 import (
+	"errors"
 	"testing"
 	"time"
 
@@ -205,3 +206,144 @@ func TestSubVolumePath(t *testing.T) {
 	assert.Error(t, err)
 	assert.Equal(t, "", path)
 }
+
+var sampleSubVolumeInfo1 = []byte(`
+{
+    "atime": "2020-08-31 19:53:43",
+    "bytes_pcent": "undefined",
+    "bytes_quota": "infinite",
+    "bytes_used": 0,
+    "created_at": "2020-08-31 19:53:43",
+    "ctime": "2020-08-31 19:57:15",
+    "data_pool": "cephfs_data",
+    "gid": 0,
+    "mode": 16877,
+    "mon_addrs": [
+        "127.0.0.1:6789"
+    ],
+    "mtime": "2020-08-31 19:53:43",
+    "path": "/volumes/_nogroup/nibbles/df11be81-a648-4a7b-8549-f28306e3ad93",
+    "pool_namespace": "",
+    "type": "subvolume",
+    "uid": 0
+}
+`)
+
+var sampleSubVolumeInfo2 = []byte(`
+{
+    "atime": "2020-09-01 17:49:25",
+    "bytes_pcent": "0.00",
+    "bytes_quota": 444444,
+    "bytes_used": 0,
+    "created_at": "2020-09-01 17:49:25",
+    "ctime": "2020-09-01 23:49:22",
+    "data_pool": "cephfs_data",
+    "gid": 0,
+    "mode": 16877,
+    "mon_addrs": [
+        "127.0.0.1:6789"
+    ],
+    "mtime": "2020-09-01 17:49:25",
+    "path": "/volumes/_nogroup/nibbles/d6e062df-7fa0-46ca-872a-9adf728e0e00",
+    "pool_namespace": "",
+    "type": "subvolume",
+    "uid": 0
+}
+`)
+
+var badSampleSubVolumeInfo1 = []byte(`
+{
+    "bytes_quota": "fishy",
+    "uid": 0
+}
+`)
+
+var badSampleSubVolumeInfo2 = []byte(`
+{
+    "bytes_quota": true,
+    "uid": 0
+}
+`)
+
+func TestParseSubVolumeInfo(t *testing.T) {
+	t.Run("error", func(t *testing.T) {
+		_, err := parseSubVolumeInfo(nil, "", errors.New("gleep glop"))
+		assert.Error(t, err)
+		assert.Equal(t, "gleep glop", err.Error())
+	})
+	t.Run("statusSet", func(t *testing.T) {
+		_, err := parseSubVolumeInfo(nil, "unexpected!", nil)
+		assert.Error(t, err)
+	})
+	t.Run("ok", func(t *testing.T) {
+		info, err := parseSubVolumeInfo(sampleSubVolumeInfo1, "", nil)
+		assert.NoError(t, err)
+		if assert.NotNil(t, info) {
+			assert.Equal(t,
+				"/volumes/_nogroup/nibbles/df11be81-a648-4a7b-8549-f28306e3ad93",
+				info.Path)
+			assert.Equal(t, 0, info.Uid)
+			assert.Equal(t, Infinite, info.BytesQuota)
+			assert.Equal(t, 040755, info.Mode)
+			assert.Equal(t, 2020, info.Ctime.Year())
+			assert.Equal(t, "2020-08-31 19:57:15", info.Ctime.String())
+		}
+	})
+	t.Run("ok2", func(t *testing.T) {
+		info, err := parseSubVolumeInfo(sampleSubVolumeInfo2, "", nil)
+		assert.NoError(t, err)
+		if assert.NotNil(t, info) {
+			assert.Equal(t,
+				"/volumes/_nogroup/nibbles/d6e062df-7fa0-46ca-872a-9adf728e0e00",
+				info.Path)
+			assert.Equal(t, 0, info.Uid)
+			assert.Equal(t, ByteCount(444444), info.BytesQuota)
+			assert.Equal(t, 040755, info.Mode)
+			assert.Equal(t, 2020, info.Ctime.Year())
+			assert.Equal(t, "2020-09-01 23:49:22", info.Ctime.String())
+		}
+	})
+	t.Run("invalidBytesQuotaValue", func(t *testing.T) {
+		info, err := parseSubVolumeInfo(badSampleSubVolumeInfo1, "", nil)
+		assert.Error(t, err)
+		assert.Nil(t, info)
+	})
+	t.Run("invalidBytesQuotaType", func(t *testing.T) {
+		info, err := parseSubVolumeInfo(badSampleSubVolumeInfo2, "", nil)
+		assert.Error(t, err)
+		assert.Nil(t, info)
+	})
+}
+
+func TestSubVolumeInfo(t *testing.T) {
+	fsa := getFSAdmin(t)
+	volume := "cephfs"
+	group := "hoagie"
+	subname := "grinder"
+
+	err := fsa.CreateSubVolumeGroup(volume, group, nil)
+	assert.NoError(t, err)
+	defer func() {
+		err := fsa.RemoveSubVolumeGroup(volume, group)
+		assert.NoError(t, err)
+	}()
+
+	svopts := &SubVolumeOptions{
+		Mode: 0750,
+		Size: 20 * gibiByte,
+	}
+	err = fsa.CreateSubVolume(volume, group, subname, svopts)
+	assert.NoError(t, err)
+	defer func() {
+		err := fsa.RemoveSubVolume(volume, group, subname)
+		assert.NoError(t, err)
+	}()
+
+	vinfo, err := fsa.SubVolumeInfo(volume, group, subname)
+	assert.NoError(t, err)
+	assert.NotNil(t, vinfo)
+	assert.Equal(t, 0, vinfo.Uid)
+	assert.Equal(t, 20*gibiByte, vinfo.BytesQuota)
+	assert.Equal(t, 040750, vinfo.Mode)
+	assert.GreaterOrEqual(t, 2020, vinfo.Ctime.Year())
+}
diff --git a/cephfs/admin/timestamp_test.go b/cephfs/admin/timestamp_test.go
new file mode 100644
index 0000000..84be586
--- /dev/null
+++ b/cephfs/admin/timestamp_test.go
@@ -0,0 +1,44 @@
+// +build !luminous,!mimic
+
+package admin
+
+import (
+	"encoding/json"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestTimeStampUnmarshal(t *testing.T) {
+	t.Run("valid", func(t *testing.T) {
+		j1 := []byte(`"2020-01-03 18:03:21"`)
+		var ts TimeStamp
+		err := json.Unmarshal(j1, &ts)
+		assert.NoError(t, err)
+		assert.Equal(t, 2020, ts.Year())
+		assert.Equal(t, time.Month(1), ts.Month())
+		assert.Equal(t, 3, ts.Day())
+	})
+	t.Run("badType", func(t *testing.T) {
+		j1 := []byte(`["2020-01-03 18:03:21"]`)
+		var ts TimeStamp
+		err := json.Unmarshal(j1, &ts)
+		assert.Error(t, err)
+	})
+	t.Run("badValue", func(t *testing.T) {
+		j1 := []byte(`"just another manic monday"`)
+		var ts TimeStamp
+		err := json.Unmarshal(j1, &ts)
+		assert.Error(t, err)
+	})
+}
+
+func TestTimeStampString(t *testing.T) {
+	s := "2020-11-06 11:33:56"
+	ti, err := time.Parse(cephTSLayout, s)
+	if assert.NoError(t, err) {
+		ts := TimeStamp{ti}
+		assert.Equal(t, s, ts.String())
+	}
+}