github.com/artpar/rclone@v1.67.3/cmd/serve/docker/docker_test.go (about)

     1  //go:build !race
     2  
     3  package docker_test
     4  
     5  import (
     6  	"bytes"
     7  	"context"
     8  	"encoding/json"
     9  	"fmt"
    10  	"io"
    11  	"net"
    12  	"net/http"
    13  	"os"
    14  	"path/filepath"
    15  	"runtime"
    16  	"strings"
    17  	"testing"
    18  	"time"
    19  
    20  	"github.com/artpar/rclone/cmd/mountlib"
    21  	"github.com/artpar/rclone/cmd/serve/docker"
    22  	"github.com/artpar/rclone/fs"
    23  	"github.com/artpar/rclone/fs/config"
    24  	"github.com/artpar/rclone/fstest"
    25  	"github.com/artpar/rclone/fstest/testy"
    26  	"github.com/artpar/rclone/lib/file"
    27  
    28  	"github.com/stretchr/testify/assert"
    29  	"github.com/stretchr/testify/require"
    30  
    31  	_ "github.com/artpar/rclone/backend/local"
    32  	_ "github.com/artpar/rclone/backend/memory"
    33  	_ "github.com/artpar/rclone/cmd/cmount"
    34  	_ "github.com/artpar/rclone/cmd/mount"
    35  )
    36  
    37  func initialise(ctx context.Context, t *testing.T) (string, fs.Fs) {
    38  	fstest.Initialise()
    39  
    40  	// Make test cache directory
    41  	testDir, err := fstest.LocalRemote()
    42  	require.NoError(t, err)
    43  	err = file.MkdirAll(testDir, 0755)
    44  	require.NoError(t, err)
    45  
    46  	// Make test file system
    47  	testFs, err := fs.NewFs(ctx, testDir)
    48  	require.NoError(t, err)
    49  	return testDir, testFs
    50  }
    51  
    52  func assertErrorContains(t *testing.T, err error, errString string, msgAndArgs ...interface{}) {
    53  	assert.Error(t, err)
    54  	if err != nil {
    55  		assert.Contains(t, err.Error(), errString, msgAndArgs...)
    56  	}
    57  }
    58  
    59  func assertVolumeInfo(t *testing.T, v *docker.VolInfo, name, path string) {
    60  	assert.Equal(t, name, v.Name)
    61  	assert.Equal(t, path, v.Mountpoint)
    62  	assert.NotEmpty(t, v.CreatedAt)
    63  	_, err := time.Parse(time.RFC3339, v.CreatedAt)
    64  	assert.NoError(t, err)
    65  }
    66  
    67  func TestDockerPluginLogic(t *testing.T) {
    68  	ctx := context.Background()
    69  	oldCacheDir := config.GetCacheDir()
    70  	testDir, testFs := initialise(ctx, t)
    71  	err := config.SetCacheDir(testDir)
    72  	require.NoError(t, err)
    73  	defer func() {
    74  		_ = config.SetCacheDir(oldCacheDir)
    75  		if !t.Failed() {
    76  			fstest.Purge(testFs)
    77  			_ = os.RemoveAll(testDir)
    78  		}
    79  	}()
    80  
    81  	// Create dummy volume driver
    82  	drv, err := docker.NewDriver(ctx, testDir, nil, nil, true, true)
    83  	require.NoError(t, err)
    84  	require.NotNil(t, drv)
    85  
    86  	// 1st volume request
    87  	volReq := &docker.CreateRequest{
    88  		Name:    "vol1",
    89  		Options: docker.VolOpts{},
    90  	}
    91  	assertErrorContains(t, drv.Create(volReq), "volume must have either remote or backend")
    92  
    93  	volReq.Options["remote"] = testDir
    94  	assert.NoError(t, drv.Create(volReq))
    95  	path1 := filepath.Join(testDir, "vol1")
    96  
    97  	assert.ErrorIs(t, drv.Create(volReq), docker.ErrVolumeExists)
    98  
    99  	getReq := &docker.GetRequest{Name: "vol1"}
   100  	getRes, err := drv.Get(getReq)
   101  	assert.NoError(t, err)
   102  	require.NotNil(t, getRes)
   103  	assertVolumeInfo(t, getRes.Volume, "vol1", path1)
   104  
   105  	// 2nd volume request
   106  	volReq.Name = "vol2"
   107  	assert.NoError(t, drv.Create(volReq))
   108  	path2 := filepath.Join(testDir, "vol2")
   109  
   110  	listRes, err := drv.List()
   111  	require.NoError(t, err)
   112  	require.Equal(t, 2, len(listRes.Volumes))
   113  	assertVolumeInfo(t, listRes.Volumes[0], "vol1", path1)
   114  	assertVolumeInfo(t, listRes.Volumes[1], "vol2", path2)
   115  
   116  	// Try prohibited volume options
   117  	volReq.Name = "vol99"
   118  	volReq.Options["remote"] = testDir
   119  	volReq.Options["type"] = "memory"
   120  	err = drv.Create(volReq)
   121  	assertErrorContains(t, err, "volume must have either remote or backend")
   122  
   123  	volReq.Options["persist"] = "WrongBoolean"
   124  	err = drv.Create(volReq)
   125  	assertErrorContains(t, err, "cannot parse option")
   126  
   127  	volReq.Options["persist"] = "true"
   128  	delete(volReq.Options, "remote")
   129  	err = drv.Create(volReq)
   130  	assertErrorContains(t, err, "persist remotes is prohibited")
   131  
   132  	volReq.Options["persist"] = "false"
   133  	volReq.Options["memory-option-broken"] = "some-value"
   134  	err = drv.Create(volReq)
   135  	assertErrorContains(t, err, "unsupported backend option")
   136  
   137  	getReq.Name = "vol99"
   138  	getRes, err = drv.Get(getReq)
   139  	assert.Error(t, err)
   140  	assert.Nil(t, getRes)
   141  
   142  	// Test mount requests
   143  	mountReq := &docker.MountRequest{
   144  		Name: "vol2",
   145  		ID:   "id1",
   146  	}
   147  	mountRes, err := drv.Mount(mountReq)
   148  	assert.NoError(t, err)
   149  	require.NotNil(t, mountRes)
   150  	assert.Equal(t, path2, mountRes.Mountpoint)
   151  
   152  	mountRes, err = drv.Mount(mountReq)
   153  	assert.Error(t, err)
   154  	assert.Nil(t, mountRes)
   155  	assertErrorContains(t, err, "already mounted by this id")
   156  
   157  	mountReq.ID = "id2"
   158  	mountRes, err = drv.Mount(mountReq)
   159  	assert.NoError(t, err)
   160  	require.NotNil(t, mountRes)
   161  	assert.Equal(t, path2, mountRes.Mountpoint)
   162  
   163  	unmountReq := &docker.UnmountRequest{
   164  		Name: "vol2",
   165  		ID:   "id1",
   166  	}
   167  	err = drv.Unmount(unmountReq)
   168  	assert.NoError(t, err)
   169  
   170  	err = drv.Unmount(unmountReq)
   171  	assert.Error(t, err)
   172  	assertErrorContains(t, err, "not mounted by this id")
   173  
   174  	// Simulate plugin restart
   175  	drv2, err := docker.NewDriver(ctx, testDir, nil, nil, true, false)
   176  	assert.NoError(t, err)
   177  	require.NotNil(t, drv2)
   178  
   179  	// New plugin instance should pick up the saved state
   180  	listRes, err = drv2.List()
   181  	require.NoError(t, err)
   182  	require.Equal(t, 2, len(listRes.Volumes))
   183  	assertVolumeInfo(t, listRes.Volumes[0], "vol1", path1)
   184  	assertVolumeInfo(t, listRes.Volumes[1], "vol2", path2)
   185  
   186  	rmReq := &docker.RemoveRequest{Name: "vol2"}
   187  	err = drv.Remove(rmReq)
   188  	assertErrorContains(t, err, "volume is in use")
   189  
   190  	unmountReq.ID = "id1"
   191  	err = drv.Unmount(unmountReq)
   192  	assert.Error(t, err)
   193  	assertErrorContains(t, err, "not mounted by this id")
   194  
   195  	unmountReq.ID = "id2"
   196  	err = drv.Unmount(unmountReq)
   197  	assert.NoError(t, err)
   198  
   199  	err = drv.Unmount(unmountReq)
   200  	assert.EqualError(t, err, "volume is not mounted")
   201  
   202  	err = drv.Remove(rmReq)
   203  	assert.NoError(t, err)
   204  }
   205  
   206  const (
   207  	httpTimeout = 2 * time.Second
   208  	tempDelay   = 10 * time.Millisecond
   209  )
   210  
   211  type APIClient struct {
   212  	t    *testing.T
   213  	cli  *http.Client
   214  	host string
   215  }
   216  
   217  func newAPIClient(t *testing.T, host, unixPath string) *APIClient {
   218  	tr := &http.Transport{
   219  		MaxIdleConns:       1,
   220  		IdleConnTimeout:    httpTimeout,
   221  		DisableCompression: true,
   222  	}
   223  
   224  	if unixPath != "" {
   225  		tr.DialContext = func(_ context.Context, _, _ string) (net.Conn, error) {
   226  			return net.Dial("unix", unixPath)
   227  		}
   228  	} else {
   229  		dialer := &net.Dialer{
   230  			Timeout:   httpTimeout,
   231  			KeepAlive: httpTimeout,
   232  		}
   233  		tr.DialContext = dialer.DialContext
   234  	}
   235  
   236  	cli := &http.Client{
   237  		Transport: tr,
   238  		Timeout:   httpTimeout,
   239  	}
   240  	return &APIClient{
   241  		t:    t,
   242  		cli:  cli,
   243  		host: host,
   244  	}
   245  }
   246  
   247  func (a *APIClient) request(path string, in, out interface{}, wantErr bool) {
   248  	t := a.t
   249  	var (
   250  		dataIn  []byte
   251  		dataOut []byte
   252  		err     error
   253  	)
   254  
   255  	realm := "VolumeDriver"
   256  	if path == "Activate" {
   257  		realm = "Plugin"
   258  	}
   259  	url := fmt.Sprintf("http://%s/%s.%s", a.host, realm, path)
   260  
   261  	if str, isString := in.(string); isString {
   262  		dataIn = []byte(str)
   263  	} else {
   264  		dataIn, err = json.Marshal(in)
   265  		require.NoError(t, err)
   266  	}
   267  	fs.Logf(path, "<-- %s", dataIn)
   268  
   269  	req, err := http.NewRequest("POST", url, bytes.NewBuffer(dataIn))
   270  	require.NoError(t, err)
   271  	req.Header.Set("Content-Type", "application/json")
   272  
   273  	res, err := a.cli.Do(req)
   274  	require.NoError(t, err)
   275  
   276  	wantStatus := http.StatusOK
   277  	if wantErr {
   278  		wantStatus = http.StatusInternalServerError
   279  	}
   280  	assert.Equal(t, wantStatus, res.StatusCode)
   281  
   282  	dataOut, err = io.ReadAll(res.Body)
   283  	require.NoError(t, err)
   284  	err = res.Body.Close()
   285  	require.NoError(t, err)
   286  
   287  	if strPtr, isString := out.(*string); isString || wantErr {
   288  		require.True(t, isString, "must use string for error response")
   289  		if wantErr {
   290  			var errRes docker.ErrorResponse
   291  			err = json.Unmarshal(dataOut, &errRes)
   292  			require.NoError(t, err)
   293  			*strPtr = errRes.Err
   294  		} else {
   295  			*strPtr = strings.TrimSpace(string(dataOut))
   296  		}
   297  	} else {
   298  		err = json.Unmarshal(dataOut, out)
   299  		require.NoError(t, err)
   300  	}
   301  	fs.Logf(path, "--> %s", dataOut)
   302  	time.Sleep(tempDelay)
   303  }
   304  
   305  func testMountAPI(t *testing.T, sockAddr string) {
   306  	// Disable tests under macOS and linux in the CI since they are locking up
   307  	if runtime.GOOS == "darwin" || runtime.GOOS == "linux" {
   308  		testy.SkipUnreliable(t)
   309  	}
   310  	if _, mountFn := mountlib.ResolveMountMethod(""); mountFn == nil {
   311  		t.Skip("Test requires working mount command")
   312  	}
   313  
   314  	ctx := context.Background()
   315  	oldCacheDir := config.GetCacheDir()
   316  	testDir, testFs := initialise(ctx, t)
   317  	err := config.SetCacheDir(testDir)
   318  	require.NoError(t, err)
   319  	defer func() {
   320  		_ = config.SetCacheDir(oldCacheDir)
   321  		if !t.Failed() {
   322  			fstest.Purge(testFs)
   323  			_ = os.RemoveAll(testDir)
   324  		}
   325  	}()
   326  
   327  	// Prepare API client
   328  	var cli *APIClient
   329  	var unixPath string
   330  	if sockAddr != "" {
   331  		cli = newAPIClient(t, sockAddr, "")
   332  	} else {
   333  		unixPath = filepath.Join(testDir, "rclone.sock")
   334  		cli = newAPIClient(t, "localhost", unixPath)
   335  	}
   336  
   337  	// Create mounting volume driver and listen for requests
   338  	drv, err := docker.NewDriver(ctx, testDir, nil, nil, false, true)
   339  	require.NoError(t, err)
   340  	require.NotNil(t, drv)
   341  	defer drv.Exit()
   342  
   343  	srv := docker.NewServer(drv)
   344  	go func() {
   345  		var errServe error
   346  		if unixPath != "" {
   347  			errServe = srv.ServeUnix(unixPath, os.Getgid())
   348  		} else {
   349  			errServe = srv.ServeTCP(sockAddr, testDir, nil, false)
   350  		}
   351  		assert.ErrorIs(t, errServe, http.ErrServerClosed)
   352  	}()
   353  	defer func() {
   354  		err := srv.Shutdown(ctx)
   355  		assert.NoError(t, err)
   356  		fs.Logf(nil, "Server stopped")
   357  		time.Sleep(tempDelay)
   358  	}()
   359  	time.Sleep(tempDelay) // Let server start
   360  
   361  	// Run test sequence
   362  	path1 := filepath.Join(testDir, "path1")
   363  	require.NoError(t, file.MkdirAll(path1, 0755))
   364  	mount1 := filepath.Join(testDir, "vol1")
   365  	res := ""
   366  
   367  	cli.request("Activate", "{}", &res, false)
   368  	assert.Contains(t, res, `"VolumeDriver"`)
   369  
   370  	createReq := docker.CreateRequest{
   371  		Name:    "vol1",
   372  		Options: docker.VolOpts{"remote": path1},
   373  	}
   374  	cli.request("Create", createReq, &res, false)
   375  	assert.Equal(t, "{}", res)
   376  	cli.request("Create", createReq, &res, true)
   377  	assert.Contains(t, res, "volume already exists")
   378  
   379  	mountReq := docker.MountRequest{Name: "vol1", ID: "id1"}
   380  	var mountRes docker.MountResponse
   381  	cli.request("Mount", mountReq, &mountRes, false)
   382  	assert.Equal(t, mount1, mountRes.Mountpoint)
   383  	cli.request("Mount", mountReq, &res, true)
   384  	assert.Contains(t, res, "already mounted by this id")
   385  
   386  	removeReq := docker.RemoveRequest{Name: "vol1"}
   387  	cli.request("Remove", removeReq, &res, true)
   388  	assert.Contains(t, res, "volume is in use")
   389  
   390  	text := []byte("banana")
   391  	err = os.WriteFile(filepath.Join(mount1, "txt"), text, 0644)
   392  	assert.NoError(t, err)
   393  	time.Sleep(tempDelay)
   394  
   395  	text2, err := os.ReadFile(filepath.Join(path1, "txt"))
   396  	assert.NoError(t, err)
   397  	if runtime.GOOS != "windows" {
   398  		// this check sometimes fails on windows - ignore
   399  		assert.Equal(t, text, text2)
   400  	}
   401  
   402  	unmountReq := docker.UnmountRequest{Name: "vol1", ID: "id1"}
   403  	cli.request("Unmount", unmountReq, &res, false)
   404  	assert.Equal(t, "{}", res)
   405  	cli.request("Unmount", unmountReq, &res, true)
   406  	assert.Equal(t, "volume is not mounted", res)
   407  
   408  	cli.request("Remove", removeReq, &res, false)
   409  	assert.Equal(t, "{}", res)
   410  	cli.request("Remove", removeReq, &res, true)
   411  	assert.Equal(t, "volume not found", res)
   412  
   413  	var listRes docker.ListResponse
   414  	cli.request("List", "{}", &listRes, false)
   415  	assert.Empty(t, listRes.Volumes)
   416  }
   417  
   418  func TestDockerPluginMountTCP(t *testing.T) {
   419  	testMountAPI(t, "localhost:53789")
   420  }
   421  
   422  func TestDockerPluginMountUnix(t *testing.T) {
   423  	if runtime.GOOS != "linux" {
   424  		t.Skip("Test is Linux-only")
   425  	}
   426  	testMountAPI(t, "")
   427  }