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 }