Merge branch '12308-cgofuse'
authorTom Clegg <tom@tomclegg.ca>
Fri, 14 Feb 2020 20:13:15 +0000 (15:13 -0500)
committerTom Clegg <tom@tomclegg.ca>
Fri, 14 Feb 2020 20:13:15 +0000 (15:13 -0500)
refs #12308

Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom@tomclegg.ca>

build/run-tests.sh
cmd/arvados-client/Makefile [new file with mode: 0644]
cmd/arvados-client/cmd.go
go.mod
go.sum
lib/mount/command.go [new file with mode: 0644]
lib/mount/command_test.go [new file with mode: 0644]
lib/mount/fs.go [new file with mode: 0644]
lib/mount/fs_test.go [new file with mode: 0644]

index c4c5335596a569d54f2a25c7414240baa9e2c4af..891faca41944469188afebb53f46291a14639e7c 100755 (executable)
@@ -90,6 +90,7 @@ lib/dispatchcloud/container
 lib/dispatchcloud/scheduler
 lib/dispatchcloud/ssh_executor
 lib/dispatchcloud/worker
+lib/mount
 lib/service
 services/api
 services/arv-git-httpd
diff --git a/cmd/arvados-client/Makefile b/cmd/arvados-client/Makefile
new file mode 100644 (file)
index 0000000..b043fc9
--- /dev/null
@@ -0,0 +1,11 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+all:
+       @printf "*** note *** due to an xgo limitation, this only works when the working tree is in GOPATH\n\n"
+       go mod download
+       docker build --tag=cgofuse --build-arg=http_proxy="$(http_proxy)" --build-arg=https_proxy="$(https_proxy)" https://github.com/arvados/cgofuse.git
+       go run github.com/karalabe/xgo --image=cgofuse --targets=linux/amd64,linux/386,darwin/amd64,darwin/386,windows/amd64,windows/386 .
+       install arvados-* "$(GOPATH)"/bin/
+       rm --interactive=never arvados-*
index bc6c7f0021eb4471586aedb8310e7f5790fc5be4..887bc62bb322a7e5df7f41ab74efd9c74d82b655 100644 (file)
@@ -9,6 +9,7 @@ import (
 
        "git.arvados.org/arvados.git/lib/cli"
        "git.arvados.org/arvados.git/lib/cmd"
+       "git.arvados.org/arvados.git/lib/mount"
 )
 
 var (
@@ -50,6 +51,8 @@ var (
                "user":                     cli.APICall,
                "virtual_machine":          cli.APICall,
                "workflow":                 cli.APICall,
+
+               "mount": mount.Command,
        })
 )
 
diff --git a/go.mod b/go.mod
index 033723d23680982c09763f2db7c0ebd6917953f5..2f1852734099cb2238c4ba6a70baa477fad3b3a8 100644 (file)
--- a/go.mod
+++ b/go.mod
@@ -9,6 +9,7 @@ require (
        github.com/Microsoft/go-winio v0.4.5 // indirect
        github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7 // indirect
        github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 // indirect
+       github.com/arvados/cgofuse v1.2.0
        github.com/aws/aws-sdk-go v1.25.30
        github.com/coreos/go-oidc v2.1.0+incompatible
        github.com/coreos/go-systemd v0.0.0-20180108085132-cc4f39464dc7
@@ -30,6 +31,7 @@ require (
        github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
        github.com/jmcvetta/randutil v0.0.0-20150817122601-2bb1b664bcff
        github.com/julienschmidt/httprouter v1.2.0
+       github.com/karalabe/xgo v0.0.0-20191115072854-c5ccff8648a7 // indirect
        github.com/kevinburke/ssh_config v0.0.0-20171013211458-802051befeb5 // indirect
        github.com/lib/pq v0.0.0-20171126050459-83612a56d3dd
        github.com/marstr/guid v1.1.1-0.20170427235115-8bdf7d1a087c // indirect
diff --git a/go.sum b/go.sum
index d7a022dda99f2672a57d3d96e56ded48861292e1..0a543fde905eb15567464ee84e74aa3629f67d3c 100644 (file)
--- a/go.sum
+++ b/go.sum
@@ -17,6 +17,8 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRF
 github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
 github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA=
 github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
+github.com/arvados/cgofuse v1.2.0 h1:sWgVxyvSFjH965Uc7ReScn/cBl9Jemc9SeUNlEmjRH4=
+github.com/arvados/cgofuse v1.2.0/go.mod h1:79WFV98hrkRHK9XPhh2IGGOwpFSjocsWubgxAs2KhRc=
 github.com/arvados/goamz v0.0.0-20190905141525-1bba09f407ef h1:cl7DIRbiAYNqaVxg3CZY8qfZoBOKrj06H/x9SPGaxas=
 github.com/arvados/goamz v0.0.0-20190905141525-1bba09f407ef/go.mod h1:rCtgyMmBGEbjTm37fCuBYbNL0IhztiALzo3OB9HyiOM=
 github.com/aws/aws-sdk-go v1.25.30 h1:I9qj6zW3mMfsg91e+GMSN/INcaX9tTFvr/l/BAHKaIY=
@@ -32,8 +34,6 @@ github.com/coreos/go-oidc v2.1.0+incompatible h1:sdJrfw8akMnCuUlaZU3tE/uYXFgfqom
 github.com/coreos/go-oidc v2.1.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc=
 github.com/coreos/go-systemd v0.0.0-20180108085132-cc4f39464dc7 h1:e3u8KWFMR3irlDo1Z/tL8Hsz1MJmCLkSoX5AZRMKZkg=
 github.com/coreos/go-systemd v0.0.0-20180108085132-cc4f39464dc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
-github.com/curoverse/goamz v0.0.0-20190905141525-1bba09f407ef h1:k3Q9m06dbTShrR4phl/QNi15ZSPkIwgyQmNvJRcXR3Y=
-github.com/curoverse/goamz v0.0.0-20190905141525-1bba09f407ef/go.mod h1:NUkr+hZ9k+l0cEXg9S7EW8+UIfPkP/hNy2Ga0QVPZ88=
 github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -104,6 +104,8 @@ github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/u
 github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
 github.com/julienschmidt/httprouter v1.2.0 h1:TDTW5Yz1mjftljbcKqRcrYhd4XeOoI98t+9HbQbYf7g=
 github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
+github.com/karalabe/xgo v0.0.0-20191115072854-c5ccff8648a7 h1:AYzjK/SHz6m6mg5iuFwkrAhCc14jvCpW9d6frC9iDPE=
+github.com/karalabe/xgo v0.0.0-20191115072854-c5ccff8648a7/go.mod h1:iYGcTYIPUvEWhFo6aKUuLchs+AV4ssYdyuBbQJZGcBk=
 github.com/kevinburke/ssh_config v0.0.0-20171013211458-802051befeb5 h1:xXn0nBttYwok7DhU4RxqaADEpQn7fEMt5kKc3yoj/n0=
 github.com/kevinburke/ssh_config v0.0.0-20171013211458-802051befeb5/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
 github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
diff --git a/lib/mount/command.go b/lib/mount/command.go
new file mode 100644 (file)
index 0000000..86a9085
--- /dev/null
@@ -0,0 +1,86 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package mount
+
+import (
+       "flag"
+       "io"
+       "log"
+       "net/http"
+       _ "net/http/pprof"
+       "os"
+
+       "git.arvados.org/arvados.git/sdk/go/arvados"
+       "git.arvados.org/arvados.git/sdk/go/arvadosclient"
+       "git.arvados.org/arvados.git/sdk/go/keepclient"
+       "github.com/arvados/cgofuse/fuse"
+)
+
+var Command = &cmd{}
+
+type cmd struct {
+       // ready, if non-nil, will be closed when the mount is
+       // initialized.  If ready is non-nil, it RunCommand() should
+       // not be called more than once, or when ready is already
+       // closed.
+       ready chan struct{}
+       // It is safe to call Unmount only after ready has been
+       // closed.
+       Unmount func() (ok bool)
+}
+
+// RunCommand implements the subcommand "mount <path> [fuse options]".
+//
+// The "-d" fuse option (and perhaps other features) ignores the
+// stderr argument and prints to os.Stderr instead.
+func (c *cmd) RunCommand(prog string, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
+       logger := log.New(stderr, prog+" ", 0)
+       flags := flag.NewFlagSet(prog, flag.ContinueOnError)
+       ro := flags.Bool("ro", false, "read-only")
+       experimental := flags.Bool("experimental", false, "acknowledge this is an experimental command, and should not be used in production (required)")
+       blockCache := flags.Int("block-cache", 4, "read cache size (number of 64MiB blocks)")
+       pprof := flags.String("pprof", "", "serve Go profile data at `[addr]:port`")
+       err := flags.Parse(args)
+       if err != nil {
+               logger.Print(err)
+               return 2
+       }
+       if !*experimental {
+               logger.Printf("error: experimental command %q used without --experimental flag", prog)
+               return 2
+       }
+       if *pprof != "" {
+               go func() {
+                       log.Println(http.ListenAndServe(*pprof, nil))
+               }()
+       }
+
+       client := arvados.NewClientFromEnv()
+       ac, err := arvadosclient.New(client)
+       if err != nil {
+               logger.Print(err)
+               return 1
+       }
+       kc, err := keepclient.MakeKeepClient(ac)
+       if err != nil {
+               logger.Print(err)
+               return 1
+       }
+       kc.BlockCache = &keepclient.BlockCache{MaxBlocks: *blockCache}
+       host := fuse.NewFileSystemHost(&keepFS{
+               Client:     client,
+               KeepClient: kc,
+               ReadOnly:   *ro,
+               Uid:        os.Getuid(),
+               Gid:        os.Getgid(),
+               ready:      c.ready,
+       })
+       c.Unmount = host.Unmount
+       ok := host.Mount("", flags.Args())
+       if !ok {
+               return 1
+       }
+       return 0
+}
diff --git a/lib/mount/command_test.go b/lib/mount/command_test.go
new file mode 100644 (file)
index 0000000..980b7d2
--- /dev/null
@@ -0,0 +1,81 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package mount
+
+import (
+       "bytes"
+       "encoding/json"
+       "io/ioutil"
+       "os"
+       "time"
+
+       "git.arvados.org/arvados.git/sdk/go/arvadostest"
+       check "gopkg.in/check.v1"
+)
+
+var _ = check.Suite(&CmdSuite{})
+
+type CmdSuite struct {
+       mnt string
+}
+
+func (s *CmdSuite) SetUpTest(c *check.C) {
+       tmpdir, err := ioutil.TempDir("", "")
+       c.Assert(err, check.IsNil)
+       s.mnt = tmpdir
+}
+
+func (s *CmdSuite) TearDownTest(c *check.C) {
+       c.Check(os.RemoveAll(s.mnt), check.IsNil)
+}
+
+func (s *CmdSuite) TestMount(c *check.C) {
+       exited := make(chan int)
+       stdin := bytes.NewBufferString("stdin")
+       stdout := bytes.NewBuffer(nil)
+       stderr := bytes.NewBuffer(nil)
+       mountCmd := cmd{ready: make(chan struct{})}
+       ready := false
+       go func() {
+               exited <- mountCmd.RunCommand("test mount", []string{"--experimental", s.mnt}, stdin, stdout, stderr)
+       }()
+       go func() {
+               <-mountCmd.ready
+               ready = true
+
+               f, err := os.Open(s.mnt + "/by_id/" + arvadostest.FooCollection)
+               if c.Check(err, check.IsNil) {
+                       dirnames, err := f.Readdirnames(-1)
+                       c.Check(err, check.IsNil)
+                       c.Check(dirnames, check.DeepEquals, []string{"foo"})
+                       f.Close()
+               }
+
+               buf, err := ioutil.ReadFile(s.mnt + "/by_id/" + arvadostest.FooCollection + "/.arvados#collection")
+               if c.Check(err, check.IsNil) {
+                       var m map[string]interface{}
+                       err = json.Unmarshal(buf, &m)
+                       c.Check(err, check.IsNil)
+                       c.Check(m["manifest_text"], check.Matches, `\. acbd.* 0:3:foo\n`)
+               }
+
+               _, err = os.Open(s.mnt + "/by_id/zzzzz-4zz18-does-not-exist")
+               c.Check(os.IsNotExist(err), check.Equals, true)
+
+               ok := mountCmd.Unmount()
+               c.Check(ok, check.Equals, true)
+       }()
+       select {
+       case <-time.After(5 * time.Second):
+               c.Fatal("timed out")
+       case errCode, ok := <-exited:
+               c.Check(ok, check.Equals, true)
+               c.Check(errCode, check.Equals, 0)
+       }
+       c.Check(ready, check.Equals, true)
+       c.Check(stdout.String(), check.Equals, "")
+       // stdin should not have been read
+       c.Check(stdin.String(), check.Equals, "stdin")
+}
diff --git a/lib/mount/fs.go b/lib/mount/fs.go
new file mode 100644 (file)
index 0000000..c008b96
--- /dev/null
@@ -0,0 +1,392 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package mount
+
+import (
+       "io"
+       "log"
+       "os"
+       "runtime/debug"
+       "sync"
+
+       "git.arvados.org/arvados.git/sdk/go/arvados"
+       "git.arvados.org/arvados.git/sdk/go/keepclient"
+       "github.com/arvados/cgofuse/fuse"
+)
+
+// sharedFile wraps arvados.File with a sync.Mutex, so fuse can safely
+// use a single filehandle concurrently on behalf of multiple
+// threads/processes.
+type sharedFile struct {
+       arvados.File
+       sync.Mutex
+}
+
+// keepFS implements cgofuse's FileSystemInterface.
+type keepFS struct {
+       fuse.FileSystemBase
+       Client     *arvados.Client
+       KeepClient *keepclient.KeepClient
+       ReadOnly   bool
+       Uid        int
+       Gid        int
+
+       root   arvados.CustomFileSystem
+       open   map[uint64]*sharedFile
+       lastFH uint64
+       sync.RWMutex
+
+       // If non-nil, this channel will be closed by Init() to notify
+       // other goroutines that the mount is ready.
+       ready chan struct{}
+}
+
+var (
+       invalidFH = ^uint64(0)
+)
+
+// newFH wraps f in a sharedFile, adds it to fs's lookup table using a
+// new handle number, and returns the handle number.
+func (fs *keepFS) newFH(f arvados.File) uint64 {
+       fs.Lock()
+       defer fs.Unlock()
+       if fs.open == nil {
+               fs.open = make(map[uint64]*sharedFile)
+       }
+       fs.lastFH++
+       fh := fs.lastFH
+       fs.open[fh] = &sharedFile{File: f}
+       return fh
+}
+
+func (fs *keepFS) lookupFH(fh uint64) *sharedFile {
+       fs.RLock()
+       defer fs.RUnlock()
+       return fs.open[fh]
+}
+
+func (fs *keepFS) Init() {
+       defer fs.debugPanics()
+       fs.root = fs.Client.SiteFileSystem(fs.KeepClient)
+       fs.root.MountProject("home", "")
+       if fs.ready != nil {
+               close(fs.ready)
+       }
+}
+
+func (fs *keepFS) Create(path string, flags int, mode uint32) (errc int, fh uint64) {
+       defer fs.debugPanics()
+       if fs.ReadOnly {
+               return -fuse.EROFS, invalidFH
+       }
+       f, err := fs.root.OpenFile(path, flags|os.O_CREATE, os.FileMode(mode))
+       if err == os.ErrExist {
+               return -fuse.EEXIST, invalidFH
+       } else if err != nil {
+               return -fuse.EINVAL, invalidFH
+       }
+       return 0, fs.newFH(f)
+}
+
+func (fs *keepFS) Open(path string, flags int) (errc int, fh uint64) {
+       defer fs.debugPanics()
+       if fs.ReadOnly && flags&(os.O_RDWR|os.O_WRONLY|os.O_CREATE) != 0 {
+               return -fuse.EROFS, invalidFH
+       }
+       f, err := fs.root.OpenFile(path, flags, 0)
+       if err != nil {
+               return -fuse.ENOENT, invalidFH
+       } else if fi, err := f.Stat(); err != nil {
+               return -fuse.EIO, invalidFH
+       } else if fi.IsDir() {
+               f.Close()
+               return -fuse.EISDIR, invalidFH
+       }
+       return 0, fs.newFH(f)
+}
+
+func (fs *keepFS) Utimens(path string, tmsp []fuse.Timespec) int {
+       defer fs.debugPanics()
+       if fs.ReadOnly {
+               return -fuse.EROFS
+       }
+       f, err := fs.root.OpenFile(path, 0, 0)
+       if err != nil {
+               return fs.errCode(err)
+       }
+       f.Close()
+       return 0
+}
+
+func (fs *keepFS) errCode(err error) int {
+       if os.IsNotExist(err) {
+               return -fuse.ENOENT
+       }
+       switch err {
+       case os.ErrExist:
+               return -fuse.EEXIST
+       case arvados.ErrInvalidArgument:
+               return -fuse.EINVAL
+       case arvados.ErrInvalidOperation:
+               return -fuse.ENOSYS
+       case arvados.ErrDirectoryNotEmpty:
+               return -fuse.ENOTEMPTY
+       case nil:
+               return 0
+       default:
+               return -fuse.EIO
+       }
+}
+
+func (fs *keepFS) Mkdir(path string, mode uint32) int {
+       defer fs.debugPanics()
+       if fs.ReadOnly {
+               return -fuse.EROFS
+       }
+       f, err := fs.root.OpenFile(path, os.O_CREATE|os.O_EXCL, os.FileMode(mode)|os.ModeDir)
+       if err != nil {
+               return fs.errCode(err)
+       }
+       f.Close()
+       return 0
+}
+
+func (fs *keepFS) Opendir(path string) (errc int, fh uint64) {
+       defer fs.debugPanics()
+       f, err := fs.root.OpenFile(path, 0, 0)
+       if err != nil {
+               return fs.errCode(err), invalidFH
+       } else if fi, err := f.Stat(); err != nil {
+               return fs.errCode(err), invalidFH
+       } else if !fi.IsDir() {
+               f.Close()
+               return -fuse.ENOTDIR, invalidFH
+       }
+       return 0, fs.newFH(f)
+}
+
+func (fs *keepFS) Releasedir(path string, fh uint64) (errc int) {
+       defer fs.debugPanics()
+       return fs.Release(path, fh)
+}
+
+func (fs *keepFS) Rmdir(path string) int {
+       defer fs.debugPanics()
+       return fs.errCode(fs.root.Remove(path))
+}
+
+func (fs *keepFS) Release(path string, fh uint64) (errc int) {
+       defer fs.debugPanics()
+       fs.Lock()
+       defer fs.Unlock()
+       defer delete(fs.open, fh)
+       if f := fs.open[fh]; f != nil {
+               err := f.Close()
+               if err != nil {
+                       return -fuse.EIO
+               }
+       }
+       return 0
+}
+
+func (fs *keepFS) Rename(oldname, newname string) (errc int) {
+       defer fs.debugPanics()
+       if fs.ReadOnly {
+               return -fuse.EROFS
+       }
+       return fs.errCode(fs.root.Rename(oldname, newname))
+}
+
+func (fs *keepFS) Unlink(path string) (errc int) {
+       defer fs.debugPanics()
+       if fs.ReadOnly {
+               return -fuse.EROFS
+       }
+       return fs.errCode(fs.root.Remove(path))
+}
+
+func (fs *keepFS) Truncate(path string, size int64, fh uint64) (errc int) {
+       defer fs.debugPanics()
+       if fs.ReadOnly {
+               return -fuse.EROFS
+       }
+
+       // Sometimes fh is a valid filehandle and we don't need to
+       // waste a name lookup.
+       if f := fs.lookupFH(fh); f != nil {
+               return fs.errCode(f.Truncate(size))
+       }
+
+       // Other times, fh is invalid and we need to lookup path.
+       f, err := fs.root.OpenFile(path, os.O_RDWR, 0)
+       if err != nil {
+               return fs.errCode(err)
+       }
+       defer f.Close()
+       return fs.errCode(f.Truncate(size))
+}
+
+func (fs *keepFS) Getattr(path string, stat *fuse.Stat_t, fh uint64) (errc int) {
+       defer fs.debugPanics()
+       var fi os.FileInfo
+       var err error
+       if f := fs.lookupFH(fh); f != nil {
+               // Valid filehandle -- ignore path.
+               fi, err = f.Stat()
+       } else {
+               // Invalid filehandle -- lookup path.
+               fi, err = fs.root.Stat(path)
+       }
+       if err != nil {
+               return fs.errCode(err)
+       }
+       fs.fillStat(stat, fi)
+       return 0
+}
+
+func (fs *keepFS) Chmod(path string, mode uint32) (errc int) {
+       if fs.ReadOnly {
+               return -fuse.EROFS
+       }
+       if fi, err := fs.root.Stat(path); err != nil {
+               return fs.errCode(err)
+       } else if mode & ^uint32(fuse.S_IFREG|fuse.S_IFDIR|0777) != 0 {
+               // Refuse to set mode bits other than
+               // regfile/dir/perms
+               return -fuse.ENOSYS
+       } else if (fi.Mode()&os.ModeDir != 0) != (mode&fuse.S_IFDIR != 0) {
+               // Refuse to transform a regular file to a dir, or
+               // vice versa
+               return -fuse.ENOSYS
+       }
+       // As long as the change isn't nonsense, chmod is a no-op,
+       // because we don't save permission bits.
+       return 0
+}
+
+func (fs *keepFS) fillStat(stat *fuse.Stat_t, fi os.FileInfo) {
+       defer fs.debugPanics()
+       var m uint32
+       if fi.IsDir() {
+               m = m | fuse.S_IFDIR
+       } else {
+               m = m | fuse.S_IFREG
+       }
+       m = m | uint32(fi.Mode()&os.ModePerm)
+       stat.Mode = m
+       stat.Nlink = 1
+       stat.Size = fi.Size()
+       t := fuse.NewTimespec(fi.ModTime())
+       stat.Mtim = t
+       stat.Ctim = t
+       stat.Atim = t
+       stat.Birthtim = t
+       stat.Blksize = 1024
+       stat.Blocks = (stat.Size + stat.Blksize - 1) / stat.Blksize
+       if fs.Uid > 0 && int64(fs.Uid) < 1<<31 {
+               stat.Uid = uint32(fs.Uid)
+       }
+       if fs.Gid > 0 && int64(fs.Gid) < 1<<31 {
+               stat.Gid = uint32(fs.Gid)
+       }
+}
+
+func (fs *keepFS) Write(path string, buf []byte, ofst int64, fh uint64) (n int) {
+       defer fs.debugPanics()
+       if fs.ReadOnly {
+               return -fuse.EROFS
+       }
+       f := fs.lookupFH(fh)
+       if f == nil {
+               return -fuse.EBADF
+       }
+       f.Lock()
+       defer f.Unlock()
+       if _, err := f.Seek(ofst, io.SeekStart); err != nil {
+               return fs.errCode(err)
+       }
+       n, err := f.Write(buf)
+       if err != nil {
+               log.Printf("error writing %q: %s", path, err)
+               return fs.errCode(err)
+       }
+       return n
+}
+
+func (fs *keepFS) Read(path string, buf []byte, ofst int64, fh uint64) (n int) {
+       defer fs.debugPanics()
+       f := fs.lookupFH(fh)
+       if f == nil {
+               return -fuse.EBADF
+       }
+       f.Lock()
+       defer f.Unlock()
+       if _, err := f.Seek(ofst, io.SeekStart); err != nil {
+               return fs.errCode(err)
+       }
+       n, err := f.Read(buf)
+       for err == nil && n < len(buf) {
+               // f is an io.Reader ("If some data is available but
+               // not len(p) bytes, Read conventionally returns what
+               // is available instead of waiting for more") -- but
+               // our caller requires us to either fill buf or reach
+               // EOF.
+               done := n
+               n, err = f.Read(buf[done:])
+               n += done
+       }
+       if err != nil && err != io.EOF {
+               log.Printf("error reading %q: %s", path, err)
+               return fs.errCode(err)
+       }
+       return n
+}
+
+func (fs *keepFS) Readdir(path string,
+       fill func(name string, stat *fuse.Stat_t, ofst int64) bool,
+       ofst int64,
+       fh uint64) (errc int) {
+       defer fs.debugPanics()
+       f := fs.lookupFH(fh)
+       if f == nil {
+               return -fuse.EBADF
+       }
+       fill(".", nil, 0)
+       fill("..", nil, 0)
+       var stat fuse.Stat_t
+       fis, err := f.Readdir(-1)
+       if err != nil {
+               return fs.errCode(err)
+       }
+       for _, fi := range fis {
+               fs.fillStat(&stat, fi)
+               fill(fi.Name(), &stat, 0)
+       }
+       return 0
+}
+
+func (fs *keepFS) Fsync(path string, datasync bool, fh uint64) int {
+       defer fs.debugPanics()
+       f := fs.lookupFH(fh)
+       if f == nil {
+               return -fuse.EBADF
+       }
+       return fs.errCode(f.Sync())
+}
+
+func (fs *keepFS) Fsyncdir(path string, datasync bool, fh uint64) int {
+       return fs.Fsync(path, datasync, fh)
+}
+
+// debugPanics (when deferred by keepFS handlers) prints an error and
+// stack trace on stderr when a handler crashes. (Without this,
+// cgofuse recovers from panics silently and returns EIO.)
+func (fs *keepFS) debugPanics() {
+       if err := recover(); err != nil {
+               log.Printf("(%T) %v", err, err)
+               debug.PrintStack()
+               panic(err)
+       }
+}
diff --git a/lib/mount/fs_test.go b/lib/mount/fs_test.go
new file mode 100644 (file)
index 0000000..fef2c0f
--- /dev/null
@@ -0,0 +1,49 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package mount
+
+import (
+       "testing"
+
+       "git.arvados.org/arvados.git/sdk/go/arvados"
+       "git.arvados.org/arvados.git/sdk/go/arvadosclient"
+       "git.arvados.org/arvados.git/sdk/go/keepclient"
+       "github.com/arvados/cgofuse/fuse"
+       check "gopkg.in/check.v1"
+)
+
+// Gocheck boilerplate
+func Test(t *testing.T) {
+       check.TestingT(t)
+}
+
+var _ = check.Suite(&FSSuite{})
+
+type FSSuite struct{}
+
+func (*FSSuite) TestFuseInterface(c *check.C) {
+       var _ fuse.FileSystemInterface = &keepFS{}
+}
+
+func (*FSSuite) TestOpendir(c *check.C) {
+       client := arvados.NewClientFromEnv()
+       ac, err := arvadosclient.New(client)
+       c.Assert(err, check.IsNil)
+       kc, err := keepclient.MakeKeepClient(ac)
+       c.Assert(err, check.IsNil)
+
+       var fs fuse.FileSystemInterface = &keepFS{
+               Client:     client,
+               KeepClient: kc,
+       }
+       fs.Init()
+       errc, fh := fs.Opendir("/by_id")
+       c.Check(errc, check.Equals, 0)
+       c.Check(fh, check.Not(check.Equals), uint64(0))
+       c.Check(fh, check.Not(check.Equals), invalidFH)
+       errc, fh = fs.Opendir("/bogus")
+       c.Check(errc, check.Equals, -fuse.ENOENT)
+       c.Check(fh, check.Equals, invalidFH)
+}