Repository: ceph/go-ceph (go)

Issue:
cephfs admin: need functions to map between fs-id & fs-name
As part of the cephfs admin package (issue #341) we need something to convert between fs-id an fs-name and vice versa.

Pull request description:
cephfs admin: add EnumerateVolumes for getting volumes names and ids
Add the EnumerateVolumes who's return value can also be treated like a mapping between ID<->names and used to convert between them. 
The enumeration is based on `ceph fs dump` like JSON, but for implementation only. The dump feature itself is not currently exported in a raw form.

Fixes #356 


## 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/fsadmin.go b/cephfs/admin/fsadmin.go
index 606759d..1f0c6ed 100644
--- a/cephfs/admin/fsadmin.go
+++ b/cephfs/admin/fsadmin.go
@@ -14,6 +14,7 @@ import (
 // allow the cephfs administrative functions to interact with the Ceph cluster.
 type RadosCommander interface {
 	MgrCommand(buf [][]byte) ([]byte, string, error)
+	MonCommand(buf []byte) ([]byte, string, error)
 }
 
 // FSAdmin is used to administrate CephFS within a ceph cluster.
@@ -76,6 +77,25 @@ func (fsa *FSAdmin) marshalMgrCommand(v interface{}) ([]byte, string, error) {
 	return fsa.rawMgrCommand(b)
 }
 
+// rawMonCommand takes a byte buffer and sends it to the MON as a command.
+// The buffer is expected to contain preformatted JSON.
+func (fsa *FSAdmin) rawMonCommand(buf []byte) ([]byte, string, error) {
+	if err := fsa.validate(); err != nil {
+		return nil, "", err
+	}
+	return fsa.conn.MonCommand(buf)
+}
+
+// marshalMonCommand takes an generic interface{} value, converts it to JSON and
+// sends the json to the MGR as a command.
+func (fsa *FSAdmin) marshalMonCommand(v interface{}) ([]byte, string, error) {
+	b, err := json.Marshal(v)
+	if err != nil {
+		return nil, "", err
+	}
+	return fsa.rawMonCommand(b)
+}
+
 type listNamedResult struct {
 	Name string `json:"name"`
 }
diff --git a/cephfs/admin/volume.go b/cephfs/admin/volume.go
index 226fef9..f77ffa9 100644
--- a/cephfs/admin/volume.go
+++ b/cephfs/admin/volume.go
@@ -2,7 +2,10 @@
 
 package admin
 
-var listVolumesCmd = []byte(`{"prefix":"fs volume ls"}`)
+var (
+	listVolumesCmd = []byte(`{"prefix":"fs volume ls"}`)
+	dumpVolumesCmd = []byte(`{"prefix":"fs dump","format":"json"}`)
+)
 
 // ListVolumes return a list of volumes in this Ceph cluster.
 //
@@ -12,3 +15,55 @@ func (fsa *FSAdmin) ListVolumes() ([]string, error) {
 	r, s, err := fsa.rawMgrCommand(listVolumesCmd)
 	return parseListNames(r, s, err)
 }
+
+// VolumeIdent contains a pair of file system identifying values: the volume
+// name and the volume ID.
+type VolumeIdent struct {
+	Name string
+	ID   int64
+}
+
+type cephFileSystem struct {
+	ID     int64 `json:"id"`
+	MDSMap struct {
+		FSName string `json:"fs_name"`
+	} `json:"mdsmap"`
+}
+
+type fsDump struct {
+	FileSystems []cephFileSystem `json:"filesystems"`
+}
+
+const (
+	dumpOkPrefix = "dumped fsmap epoch"
+	dumpOkLen    = len(dumpOkPrefix)
+)
+
+func parseDumpToIdents(r []byte, s string, err error) ([]VolumeIdent, error) {
+	if len(s) >= dumpOkLen && s[:dumpOkLen] == dumpOkPrefix {
+		// Unhelpfully, ceph drops a status string on success responses for this
+		// call. this hacks around that by ignoring its typical prefix
+		s = ""
+	}
+	var dump fsDump
+	if err := unmarshalResponseJSON(r, s, err, &dump); err != nil {
+		return nil, err
+	}
+	// copy the dump json into the simpler enumeration list
+	idents := make([]VolumeIdent, len(dump.FileSystems))
+	for i := range dump.FileSystems {
+		idents[i].ID = dump.FileSystems[i].ID
+		idents[i].Name = dump.FileSystems[i].MDSMap.FSName
+	}
+	return idents, nil
+}
+
+// EnumerateVolumes returns a list of volume-name volume-id pairs.
+func (fsa *FSAdmin) EnumerateVolumes() ([]VolumeIdent, error) {
+	// We base our enumeration on the ceph fs dump json.  This may not be the
+	// only way to do it, but it's the only one I know of currently. Because of
+	// this and to keep our initial implementation simple, we expose our own
+	// simplified type only, rather do a partial implementation of dump.
+	r, s, err := fsa.rawMonCommand(dumpVolumesCmd)
+	return parseDumpToIdents(r, s, err)
+}


Test patch:
diff --git a/cephfs/admin/fsadmin_test.go b/cephfs/admin/fsadmin_test.go
index 928eacf..05dd11b 100644
--- a/cephfs/admin/fsadmin_test.go
+++ b/cephfs/admin/fsadmin_test.go
@@ -42,6 +42,7 @@ func tracer(c RadosCommander) RadosCommander {
 }
 
 func (t *tracingCommander) MgrCommand(buf [][]byte) ([]byte, string, error) {
+	fmt.Println("(MGR Command)")
 	for i := range buf {
 		fmt.Println("IN:", string(buf[i]))
 	}
@@ -56,6 +57,20 @@ func (t *tracingCommander) MgrCommand(buf [][]byte) ([]byte, string, error) {
 	return r, s, err
 }
 
+func (t *tracingCommander) MonCommand(buf []byte) ([]byte, string, error) {
+	fmt.Println("(MON Command)")
+	fmt.Println("IN:", string(buf))
+	r, s, err := t.conn.MonCommand(buf)
+	fmt.Println("OUT(result):", string(r))
+	if s != "" {
+		fmt.Println("OUT(status):", s)
+	}
+	if err != nil {
+		fmt.Println("OUT(error):", err.Error())
+	}
+	return r, s, err
+}
+
 func getFSAdmin(t *testing.T) *FSAdmin {
 	if cachedFSAdmin != nil {
 		return cachedFSAdmin
diff --git a/cephfs/admin/volume_test.go b/cephfs/admin/volume_test.go
index 98aebf4..770acb4 100644
--- a/cephfs/admin/volume_test.go
+++ b/cephfs/admin/volume_test.go
@@ -3,6 +3,7 @@
 package admin
 
 import (
+	"errors"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -16,3 +17,161 @@ func TestListVolumes(t *testing.T) {
 	assert.Len(t, vl, 1)
 	assert.Equal(t, "cephfs", vl[0])
 }
+
+func TestEnumerateVolumes(t *testing.T) {
+	fsa := getFSAdmin(t)
+
+	ve, err := fsa.EnumerateVolumes()
+	assert.NoError(t, err)
+	if assert.Len(t, ve, 1) {
+		assert.Equal(t, "cephfs", ve[0].Name)
+		assert.Equal(t, int64(1), ve[0].ID)
+	}
+}
+
+// note: some of these dumps are simplified for testing purposes if we add
+// general dump support these samples may need to be expanded upon.
+var sampleDump1 = []byte(`
+{
+  "epoch": 5,
+  "default_fscid": 1,
+  "filesystems": [
+    {
+      "mdsmap": {
+        "epoch": 5,
+        "flags": 18,
+        "ever_allowed_features": 0,
+        "explicitly_allowed_features": 0,
+        "created": "2020-08-31T18:37:34.657633+0000",
+        "modified": "2020-08-31T18:37:36.700989+0000",
+        "tableserver": 0,
+        "root": 0,
+        "session_timeout": 60,
+        "session_autoclose": 300,
+        "min_compat_client": "0 (unknown)",
+        "max_file_size": 1099511627776,
+        "last_failure": 0,
+        "last_failure_osd_epoch": 0,
+        "compat": {
+          "compat": {},
+          "ro_compat": {},
+          "incompat": {
+            "feature_1": "base v0.20",
+            "feature_2": "client writeable ranges",
+            "feature_3": "default file layouts on dirs",
+            "feature_4": "dir inode in separate object",
+            "feature_5": "mds uses versioned encoding",
+            "feature_6": "dirfrag is stored in omap",
+            "feature_8": "no anchor table",
+            "feature_9": "file layout v2",
+            "feature_10": "snaprealm v2"
+          }
+        },
+        "max_mds": 1,
+        "in": [
+          0
+        ],
+        "up": {
+          "mds_0": 4115
+        },
+        "failed": [],
+        "damaged": [],
+        "stopped": [],
+        "info": {
+          "gid_4115": {
+            "gid": 4115,
+            "name": "Z",
+            "rank": 0,
+            "incarnation": 4,
+            "state": "up:active",
+            "state_seq": 2,
+            "addr": "127.0.0.1:6809/2568111595",
+            "addrs": {
+              "addrvec": [
+                {
+                  "type": "v1",
+                  "addr": "127.0.0.1:6809",
+                  "nonce": 2568111595
+                }
+              ]
+            },
+            "join_fscid": -1,
+            "export_targets": [],
+            "features": 4540138292836696000,
+            "flags": 0
+          }
+        },
+        "data_pools": [
+          1
+        ],
+        "metadata_pool": 2,
+        "enabled": true,
+        "fs_name": "cephfs",
+        "balancer": "",
+        "standby_count_wanted": 0
+      },
+      "id": 1
+    }
+  ]
+}
+`)
+
+var sampleDump2 = []byte(`
+{
+  "epoch": 5,
+  "default_fscid": 1,
+  "filesystems": [
+    {
+      "mdsmap": {
+        "fs_name": "wiffleball",
+        "standby_count_wanted": 0
+      },
+      "id": 1
+    },
+    {
+      "mdsmap": {
+        "fs_name": "beanbag",
+        "standby_count_wanted": 0
+      },
+      "id": 2
+    }
+  ]
+}
+`)
+
+func TestParseDumpToIdents(t *testing.T) {
+	fakePrefix := dumpOkPrefix + " 5"
+	t.Run("error", func(t *testing.T) {
+		idents, err := parseDumpToIdents(nil, "", errors.New("boop"))
+		assert.Error(t, err)
+		assert.Equal(t, "boop", err.Error())
+		assert.Nil(t, idents)
+	})
+	t.Run("badStatus", func(t *testing.T) {
+		_, err := parseDumpToIdents(sampleDump1, "unexpected!", nil)
+		assert.Error(t, err)
+	})
+	t.Run("oneVolOk", func(t *testing.T) {
+		idents, err := parseDumpToIdents(sampleDump1, fakePrefix, nil)
+		assert.NoError(t, err)
+		if assert.Len(t, idents, 1) {
+			assert.Equal(t, "cephfs", idents[0].Name)
+			assert.Equal(t, int64(1), idents[0].ID)
+		}
+	})
+	t.Run("twoVolOk", func(t *testing.T) {
+		idents, err := parseDumpToIdents(sampleDump2, fakePrefix, nil)
+		assert.NoError(t, err)
+		if assert.Len(t, idents, 2) {
+			assert.Equal(t, "wiffleball", idents[0].Name)
+			assert.Equal(t, int64(1), idents[0].ID)
+			assert.Equal(t, "beanbag", idents[1].Name)
+			assert.Equal(t, int64(2), idents[1].ID)
+		}
+	})
+	t.Run("unexpectedStatus", func(t *testing.T) {
+		idents, err := parseDumpToIdents(sampleDump1, "slip-up", nil)
+		assert.Error(t, err)
+		assert.Nil(t, idents)
+	})
+}