//go:build !race package docker_test import ( "bytes" "context" "encoding/json" "fmt" "io" "net" "net/http" "os" "path/filepath" "runtime" "strings" "testing" "time" "github.com/rclone/rclone/cmd/mountlib" "github.com/rclone/rclone/cmd/serve/docker" "github.com/rclone/rclone/fs" "github.com/rclone/rclone/fs/config" "github.com/rclone/rclone/fstest" "github.com/rclone/rclone/fstest/testy" "github.com/rclone/rclone/lib/file" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" _ "github.com/rclone/rclone/backend/local" _ "github.com/rclone/rclone/backend/memory" _ "github.com/rclone/rclone/cmd/cmount" _ "github.com/rclone/rclone/cmd/mount" ) func initialise(ctx context.Context, t *testing.T) (string, fs.Fs) { fstest.Initialise() // Make test cache directory testDir, err := fstest.LocalRemote() require.NoError(t, err) err = file.MkdirAll(testDir, 0755) require.NoError(t, err) // Make test file system testFs, err := fs.NewFs(ctx, testDir) require.NoError(t, err) return testDir, testFs } func assertErrorContains(t *testing.T, err error, errString string, msgAndArgs ...interface{}) { assert.Error(t, err) if err != nil { assert.Contains(t, err.Error(), errString, msgAndArgs...) } } func assertVolumeInfo(t *testing.T, v *docker.VolInfo, name, path string) { assert.Equal(t, name, v.Name) assert.Equal(t, path, v.Mountpoint) assert.NotEmpty(t, v.CreatedAt) _, err := time.Parse(time.RFC3339, v.CreatedAt) assert.NoError(t, err) } func TestDockerPluginLogic(t *testing.T) { ctx := context.Background() oldCacheDir := config.GetCacheDir() testDir, testFs := initialise(ctx, t) err := config.SetCacheDir(testDir) require.NoError(t, err) defer func() { _ = config.SetCacheDir(oldCacheDir) if !t.Failed() { fstest.Purge(testFs) _ = os.RemoveAll(testDir) } }() // Create dummy volume driver drv, err := docker.NewDriver(ctx, testDir, nil, nil, true, true) require.NoError(t, err) require.NotNil(t, drv) // 1st volume request volReq := &docker.CreateRequest{ Name: "vol1", Options: docker.VolOpts{}, } assertErrorContains(t, drv.Create(volReq), "volume must have either remote or backend") volReq.Options["remote"] = testDir assert.NoError(t, drv.Create(volReq)) path1 := filepath.Join(testDir, "vol1") assert.ErrorIs(t, drv.Create(volReq), docker.ErrVolumeExists) getReq := &docker.GetRequest{Name: "vol1"} getRes, err := drv.Get(getReq) assert.NoError(t, err) require.NotNil(t, getRes) assertVolumeInfo(t, getRes.Volume, "vol1", path1) // 2nd volume request volReq.Name = "vol2" assert.NoError(t, drv.Create(volReq)) path2 := filepath.Join(testDir, "vol2") listRes, err := drv.List() require.NoError(t, err) require.Equal(t, 2, len(listRes.Volumes)) assertVolumeInfo(t, listRes.Volumes[0], "vol1", path1) assertVolumeInfo(t, listRes.Volumes[1], "vol2", path2) // Try prohibited volume options volReq.Name = "vol99" volReq.Options["remote"] = testDir volReq.Options["type"] = "memory" err = drv.Create(volReq) assertErrorContains(t, err, "volume must have either remote or backend") volReq.Options["persist"] = "WrongBoolean" err = drv.Create(volReq) assertErrorContains(t, err, "cannot parse option") volReq.Options["persist"] = "true" delete(volReq.Options, "remote") err = drv.Create(volReq) assertErrorContains(t, err, "persist remotes is prohibited") volReq.Options["persist"] = "false" volReq.Options["memory-option-broken"] = "some-value" err = drv.Create(volReq) assertErrorContains(t, err, "unsupported backend option") getReq.Name = "vol99" getRes, err = drv.Get(getReq) assert.Error(t, err) assert.Nil(t, getRes) // Test mount requests mountReq := &docker.MountRequest{ Name: "vol2", ID: "id1", } mountRes, err := drv.Mount(mountReq) assert.NoError(t, err) require.NotNil(t, mountRes) assert.Equal(t, path2, mountRes.Mountpoint) mountRes, err = drv.Mount(mountReq) assert.Error(t, err) assert.Nil(t, mountRes) assertErrorContains(t, err, "already mounted by this id") mountReq.ID = "id2" mountRes, err = drv.Mount(mountReq) assert.NoError(t, err) require.NotNil(t, mountRes) assert.Equal(t, path2, mountRes.Mountpoint) unmountReq := &docker.UnmountRequest{ Name: "vol2", ID: "id1", } err = drv.Unmount(unmountReq) assert.NoError(t, err) err = drv.Unmount(unmountReq) assert.Error(t, err) assertErrorContains(t, err, "not mounted by this id") // Simulate plugin restart drv2, err := docker.NewDriver(ctx, testDir, nil, nil, true, false) assert.NoError(t, err) require.NotNil(t, drv2) // New plugin instance should pick up the saved state listRes, err = drv2.List() require.NoError(t, err) require.Equal(t, 2, len(listRes.Volumes)) assertVolumeInfo(t, listRes.Volumes[0], "vol1", path1) assertVolumeInfo(t, listRes.Volumes[1], "vol2", path2) rmReq := &docker.RemoveRequest{Name: "vol2"} err = drv.Remove(rmReq) assertErrorContains(t, err, "volume is in use") unmountReq.ID = "id1" err = drv.Unmount(unmountReq) assert.Error(t, err) assertErrorContains(t, err, "not mounted by this id") unmountReq.ID = "id2" err = drv.Unmount(unmountReq) assert.NoError(t, err) err = drv.Unmount(unmountReq) assert.EqualError(t, err, "volume is not mounted") err = drv.Remove(rmReq) assert.NoError(t, err) } const ( httpTimeout = 2 * time.Second tempDelay = 10 * time.Millisecond ) type APIClient struct { t *testing.T cli *http.Client host string } func newAPIClient(t *testing.T, host, unixPath string) *APIClient { tr := &http.Transport{ MaxIdleConns: 1, IdleConnTimeout: httpTimeout, DisableCompression: true, } if unixPath != "" { tr.DialContext = func(_ context.Context, _, _ string) (net.Conn, error) { return net.Dial("unix", unixPath) } } else { dialer := &net.Dialer{ Timeout: httpTimeout, KeepAlive: httpTimeout, } tr.DialContext = dialer.DialContext } cli := &http.Client{ Transport: tr, Timeout: httpTimeout, } return &APIClient{ t: t, cli: cli, host: host, } } func (a *APIClient) request(path string, in, out interface{}, wantErr bool) { t := a.t var ( dataIn []byte dataOut []byte err error ) realm := "VolumeDriver" if path == "Activate" { realm = "Plugin" } url := fmt.Sprintf("http://%s/%s.%s", a.host, realm, path) if str, isString := in.(string); isString { dataIn = []byte(str) } else { dataIn, err = json.Marshal(in) require.NoError(t, err) } fs.Logf(path, "<-- %s", dataIn) req, err := http.NewRequest("POST", url, bytes.NewBuffer(dataIn)) require.NoError(t, err) req.Header.Set("Content-Type", "application/json") res, err := a.cli.Do(req) require.NoError(t, err) wantStatus := http.StatusOK if wantErr { wantStatus = http.StatusInternalServerError } assert.Equal(t, wantStatus, res.StatusCode) dataOut, err = io.ReadAll(res.Body) require.NoError(t, err) err = res.Body.Close() require.NoError(t, err) if strPtr, isString := out.(*string); isString || wantErr { require.True(t, isString, "must use string for error response") if wantErr { var errRes docker.ErrorResponse err = json.Unmarshal(dataOut, &errRes) require.NoError(t, err) *strPtr = errRes.Err } else { *strPtr = strings.TrimSpace(string(dataOut)) } } else { err = json.Unmarshal(dataOut, out) require.NoError(t, err) } fs.Logf(path, "--> %s", dataOut) time.Sleep(tempDelay) } func testMountAPI(t *testing.T, sockAddr string) { // Disable tests under macOS and linux in the CI since they are locking up if runtime.GOOS == "darwin" || runtime.GOOS == "linux" { testy.SkipUnreliable(t) } if _, mountFn := mountlib.ResolveMountMethod(""); mountFn == nil { t.Skip("Test requires working mount command") } ctx := context.Background() oldCacheDir := config.GetCacheDir() testDir, testFs := initialise(ctx, t) err := config.SetCacheDir(testDir) require.NoError(t, err) defer func() { _ = config.SetCacheDir(oldCacheDir) if !t.Failed() { fstest.Purge(testFs) _ = os.RemoveAll(testDir) } }() // Prepare API client var cli *APIClient var unixPath string if sockAddr != "" { cli = newAPIClient(t, sockAddr, "") } else { unixPath = filepath.Join(testDir, "rclone.sock") cli = newAPIClient(t, "localhost", unixPath) } // Create mounting volume driver and listen for requests drv, err := docker.NewDriver(ctx, testDir, nil, nil, false, true) require.NoError(t, err) require.NotNil(t, drv) defer drv.Exit() srv := docker.NewServer(drv) go func() { var errServe error if unixPath != "" { errServe = srv.ServeUnix(unixPath, os.Getgid()) } else { errServe = srv.ServeTCP(sockAddr, testDir, nil, false) } assert.ErrorIs(t, errServe, http.ErrServerClosed) }() defer func() { err := srv.Shutdown(ctx) assert.NoError(t, err) fs.Logf(nil, "Server stopped") time.Sleep(tempDelay) }() time.Sleep(tempDelay) // Let server start // Run test sequence path1 := filepath.Join(testDir, "path1") require.NoError(t, file.MkdirAll(path1, 0755)) mount1 := filepath.Join(testDir, "vol1") res := "" cli.request("Activate", "{}", &res, false) assert.Contains(t, res, `"VolumeDriver"`) createReq := docker.CreateRequest{ Name: "vol1", Options: docker.VolOpts{"remote": path1}, } cli.request("Create", createReq, &res, false) assert.Equal(t, "{}", res) cli.request("Create", createReq, &res, true) assert.Contains(t, res, "volume already exists") mountReq := docker.MountRequest{Name: "vol1", ID: "id1"} var mountRes docker.MountResponse cli.request("Mount", mountReq, &mountRes, false) assert.Equal(t, mount1, mountRes.Mountpoint) cli.request("Mount", mountReq, &res, true) assert.Contains(t, res, "already mounted by this id") removeReq := docker.RemoveRequest{Name: "vol1"} cli.request("Remove", removeReq, &res, true) assert.Contains(t, res, "volume is in use") text := []byte("banana") err = os.WriteFile(filepath.Join(mount1, "txt"), text, 0644) assert.NoError(t, err) time.Sleep(tempDelay) text2, err := os.ReadFile(filepath.Join(path1, "txt")) assert.NoError(t, err) if runtime.GOOS != "windows" { // this check sometimes fails on windows - ignore assert.Equal(t, text, text2) } unmountReq := docker.UnmountRequest{Name: "vol1", ID: "id1"} cli.request("Unmount", unmountReq, &res, false) assert.Equal(t, "{}", res) cli.request("Unmount", unmountReq, &res, true) assert.Equal(t, "volume is not mounted", res) cli.request("Remove", removeReq, &res, false) assert.Equal(t, "{}", res) cli.request("Remove", removeReq, &res, true) assert.Equal(t, "volume not found", res) var listRes docker.ListResponse cli.request("List", "{}", &listRes, false) assert.Empty(t, listRes.Volumes) } func TestDockerPluginMountTCP(t *testing.T) { testMountAPI(t, "localhost:53789") } func TestDockerPluginMountUnix(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("Test is Linux-only") } testMountAPI(t, "") }