diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 32e5e58..e46de54 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -76,6 +76,16 @@ jobs: run: | go run ./testing/nfsmount + - name: "mount repository NFSv4.1" + if: runner.os != 'Windows' + run: | + go run ./testing/nfsmount -repo + + - name: "verify repository mount" + if: runner.os != 'Windows' + run: | + go test -v ./testing/ci -repository-mount + - name: "[unix] list GOMODCACHE directory" if: runner.os == 'Linux' || runner.os == 'macOS' run: | diff --git a/README.md b/README.md index a0b7d7f..a846eed 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,23 @@ Future implementations of the storage interface might include: * traditional GOMODCACHE on-disk layout * S3/etc object storage +# Experimental Git checkouts + +`gomodfs -repo -commit ` serves a read-only checkout of the +commit at `/repos//` over NFSv4.1, on a listener set by +`-repo-nfs` (default `localhost:2050`) that is separate from the module cache's +NFSv3 listener. The server has no authentication, so give `-repo-nfs` an +address on another interface only on a trusted network. + + # Linux + mount -t nfs -o vers=4.1,port=2050,ro 127.0.0.1:/repos// /mnt/checkout + # macOS + mount -t nfs -o vers=4.1,port=2050,rdonly,rsize=1048576 127.0.0.1:/repos// /mnt/checkout + +The checkout has a read-only `.git` directory with the loose objects of the +commit, so read-only Git commands such as `git status` and `git log` work. See +package `gitrepo` to serve checkouts from another program. + # Status As of 2025-07-27, this is still all very new. Use with caution. It's starting to diff --git a/cmd/gomodfs/gomodfs-main.go b/cmd/gomodfs/gomodfs-main.go index 8420910..f94340b 100644 --- a/cmd/gomodfs/gomodfs-main.go +++ b/cmd/gomodfs/gomodfs-main.go @@ -7,25 +7,31 @@ package main import ( + "context" "flag" + "fmt" "log" "net" "net/http" "net/http/pprof" + "net/url" "os" "os/exec" "path/filepath" "runtime" + "strings" "github.com/bradfitz/parentdeath" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/collectors" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/tailscale/gomodfs" + "github.com/tailscale/gomodfs/gitrepo" "github.com/tailscale/gomodfs/stats" "github.com/tailscale/gomodfs/store/gitstore" "github.com/tailscale/gomodfs/store/remotestore" "github.com/tailscale/gomodfs/temp-dev-fork/willscott/go-nfs" + "github.com/tailscale/nfsv4" ) var ( @@ -35,6 +41,9 @@ var ( flagNFS = flag.String("nfs", "", "if set, listen on this port for NFS requests") flagMountPoint = flag.String("mount", "", "if set, mount the filesystem at this path") flagMemLimitMB = flag.Int64("mem-limit-mb", 0, "how many megabytes (MiB) of memory gomodfs can use to store file contents in memory; 0 means to use a default") + flagRepo = flag.String("repo", "", "experimental: Git repository URL or scp-style [user@]host:path remote to serve as /repos// over NFSv4.1. The and are inferred from the last 2 slash-separated segments of the path") + flagCommit = flag.String("commit", "", "experimental: full commit SHA to serve from -repo") + flagRepoNFS = flag.String("repo-nfs", "localhost:2050", "NFSv4.1 listen address for -repo, separate from the module cache's -nfs listener. The server has no authentication, so listen on other interfaces only on trusted networks") portmapper = flag.Bool("portmapper", false, "if set, run rpcbind portmapper on TCP+UDP port 111 (needed for Windows NFS clients). For NFS mode only") flagWinFSP = flag.Bool("winfsp", false, "if set, use WinFSP on Windows") @@ -45,6 +54,30 @@ var ( flagServeStoreAPI = flag.String("serve-store-api", "", "if set, serve the store API on this address (e.g. :8090)") ) +// repositoryName returns the "owner/repo" name of the Git remote, from the +// last two segments of its path. The remote is a URL, an scp-style +// "[user@]host:path" remote, or a local path. +func repositoryName(remote string) (string, error) { + p := remote + if strings.Contains(remote, "://") { + u, err := url.Parse(remote) + if err != nil { + return "", fmt.Errorf("invalid -repo URL %q: %w", remote, err) + } + p = u.Path + } else if host, rest, ok := strings.Cut(remote, ":"); ok && !strings.Contains(host, "/") { + // Like Git, use the scp-style syntax only if there is no slash + // before the first colon. + p = rest + } + parts := strings.Split(strings.Trim(p, "/"), "/") + if len(parts) < 2 { + return "", fmt.Errorf("invalid -repo remote %q; want a path ending in owner/repo", remote) + } + parts[len(parts)-1] = strings.TrimSuffix(parts[len(parts)-1], ".git") + return strings.Join(parts[len(parts)-2:], "/"), nil +} + func main() { flag.Parse() @@ -120,7 +153,55 @@ func main() { } } - nfsHandler := mfs.NFSHandler() + if (*flagRepo == "") != (*flagCommit == "") { + log.Fatal("-repo and -commit must be specified together") + } + if *flagRepo != "" { + name, err := repositoryName(*flagRepo) + if err != nil { + log.Fatal(err) + } + homeDir, err := os.UserHomeDir() + if err != nil { + log.Fatalf("os.UserHomeDir: %v", err) + } + manager, err := gitrepo.NewManager(filepath.Join(homeDir, ".cache", "gomodfs-repos"), map[gitrepo.RepoName]gitrepo.Config{ + gitrepo.RepoName(name): { + RemoteURL: *flagRepo, + }, + }) + if err != nil { + log.Fatal(err) + } + defer manager.Close() + manager.RegisterMetrics(reg) + co, err := manager.Checkout(context.Background(), gitrepo.RepoName(name), *flagCommit) + if err != nil { + log.Fatal(err) + } + repoFS, err := gitrepo.NewFS(gitrepo.FSOptions{ + Checkouts: []*gitrepo.Checkout{co}, + }) + if err != nil { + log.Fatal(err) + } + repoSrv := &nfsv4.Server{ + FS: repoFS, + Logf: log.Printf, + } + if *verbose { + repoSrv.Debugf = log.Printf + } + ln, err := net.Listen("tcp", *flagRepoNFS) + if err != nil { + log.Fatalf("Failed to listen on Git NFSv4 port %s: %v", *flagRepoNFS, err) + } + addr := ln.Addr().(*net.TCPAddr) + log.Printf("Git NFSv4.1 server listening at %s; to mount:\n\tmount -t nfs -o vers=4.1,port=%d,ro %s:/repos/%s /mnt/checkout", addr, addr.Port, addr.IP, name) + go func() { + log.Fatalf("Git NFSv4 server: %v", repoSrv.Serve(ln)) + }() + } if *debugListen != "" { ln, err := net.Listen("tcp", *debugListen) @@ -182,7 +263,7 @@ func main() { log.Printf("To mount:\n\t mount -o port=%d,mountport=%d,vers=3,tcp,locallocks,soft -r -t nfs localhost:/ $HOME/mnt-gomodfs", port, port) } nfsSrv := &nfs.Server{ - Handler: nfsHandler, + Handler: mfs.NFSHandler(), ForWindowsClients: *flagNFSForWindows, } go nfsSrv.Serve(ln) diff --git a/cmd/gomodfs/gomodfs-main_test.go b/cmd/gomodfs/gomodfs-main_test.go new file mode 100644 index 0000000..be7ada4 --- /dev/null +++ b/cmd/gomodfs/gomodfs-main_test.go @@ -0,0 +1,37 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package main + +import "testing" + +func TestRepositoryName(t *testing.T) { + for _, tt := range []struct { + remote, want string + }{ + {"https://github.com/tailscale/gomodfs", "tailscale/gomodfs"}, + {"https://github.com/tailscale/gomodfs.git", "tailscale/gomodfs"}, + {"https://github.com/tailscale/gomodfs/", "tailscale/gomodfs"}, + {"ssh://git@github.com/tailscale/gomodfs.git", "tailscale/gomodfs"}, + {"file:///tmp/fixture/example/repo", "example/repo"}, + {"git@github.com:tailscale/gomodfs.git", "tailscale/gomodfs"}, + {"github.com:tailscale/gomodfs", "tailscale/gomodfs"}, + {"host:/srv/git/tailscale/gomodfs.git", "tailscale/gomodfs"}, + {"/srv/git/tailscale/gomodfs.git", "tailscale/gomodfs"}, + {"./tailscale/gomodfs:x", "tailscale/gomodfs:x"}, // A slash before the colon: a path. + {"https://github.com/gomodfs", ""}, + {"git@github.com:gomodfs.git", ""}, + {"https://github.com/%zz/repo", ""}, + } { + got, err := repositoryName(tt.remote) + if tt.want == "" { + if err == nil { + t.Errorf("repositoryName(%q) = %q; want error", tt.remote, got) + } + continue + } + if err != nil || got != tt.want { + t.Errorf("repositoryName(%q) = %q, %v; want %q", tt.remote, got, err, tt.want) + } + } +} diff --git a/gitrepo/catfile.go b/gitrepo/catfile.go new file mode 100644 index 0000000..5bccf22 --- /dev/null +++ b/gitrepo/catfile.go @@ -0,0 +1,79 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package gitrepo + +import ( + "bufio" + "fmt" + "io" + "os/exec" + "strconv" + "strings" +) + +// catFile is a long-running "git cat-file --batch" process, which reads +// objects much faster than one process for each object. It is not safe for +// concurrent use. +type catFile struct { + cmd *exec.Cmd + in io.WriteCloser + out *bufio.Reader +} + +// startCatFile starts a cat-file process for the repository at dir. The +// process is not stopped by a request context, because it serves later +// requests too. +func startCatFile(dir string) (*catFile, error) { + cmd := exec.Command("git", "cat-file", "--batch") + cmd.Dir = dir + in, err := cmd.StdinPipe() + if err != nil { + return nil, err + } + out, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + if err := cmd.Start(); err != nil { + return nil, err + } + return &catFile{ + cmd: cmd, + in: in, + out: bufio.NewReaderSize(out, 64<<10), + }, nil +} + +// read returns the contents of the object id. After an error, the process is +// in an unknown state and must be closed. +func (c *catFile) read(id string) ([]byte, error) { + if _, err := fmt.Fprintf(c.in, "%s\n", id); err != nil { + return nil, err + } + // The response is " \n\n", or + // " missing\n". + header, err := c.out.ReadString('\n') + if err != nil { + return nil, err + } + f := strings.Fields(header) + if len(f) != 3 || f[0] != id { + return nil, fmt.Errorf("git cat-file: unexpected response %q for %s", header, id) + } + size, err := strconv.Atoi(f[2]) + if err != nil { + return nil, fmt.Errorf("git cat-file: unexpected response %q for %s", header, id) + } + b := make([]byte, size+1) + if _, err := io.ReadFull(c.out, b); err != nil { + return nil, err + } + return b[:size], nil +} + +// close stops the process. +func (c *catFile) close() error { + c.in.Close() + return c.cmd.Wait() +} diff --git a/gitrepo/dotgit.go b/gitrepo/dotgit.go new file mode 100644 index 0000000..b96091d --- /dev/null +++ b/gitrepo/dotgit.go @@ -0,0 +1,280 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package gitrepo + +import ( + "bytes" + "compress/zlib" + "crypto/sha1" + "encoding/binary" + "encoding/hex" + "fmt" + "io/fs" + "maps" + "path" + "slices" + + "github.com/tailscale/nfsv4" + "github.com/tailscale/nfsv4/nodefs" +) + +// newDotGit returns a read-only .git directory for co. It contains the +// minimum that Git needs to open a shallow repository: HEAD, an index, and +// the loose objects of the commit. The objects directory contains only the +// objects of this commit, so a view never shows other commits. +func newDotGit(o owner, co *Checkout) nodefs.Node { + file := func(data []byte) nodefs.Node { + return memFile{ + owner: o, + data: data, + } + } + return &staticDir{ + owner: o, + entries: []nodefs.DirEntry{ + { + Name: "HEAD", + Node: file([]byte(co.commit + "\n")), + }, + { + Name: "config", + Node: file([]byte(checkoutConfig)), + }, + { + Name: "index", + Node: file(co.objects.index), + }, + { + Name: "objects", + Node: &objectsDir{ + owner: o, + co: co, + }, + }, + { + Name: "refs", + Node: &staticDir{ + owner: o, + entries: []nodefs.DirEntry{ + { + Name: "heads", + Node: &staticDir{ + owner: o, + }, + }, + }, + }, + }, + { + // The parents of the commit are not available. + Name: "shallow", + Node: file([]byte(co.commit + "\n")), + }, + }, + } +} + +// checkoutConfig is .git/config. It tells Git not to examine the files of the +// checkout, which cannot change: +// +// - Index preloading does not obey the assume-unchanged flag (see +// [encodeIndex]), and would stat every file. +// - The checkout is read-only, so it cannot contain untracked files, and +// git status does not need to read every directory to look for them. +const checkoutConfig = `[core] + repositoryformatversion = 0 + bare = false + preloadIndex = false +[status] + showUntrackedFiles = no +` + +// objects is the set of objects of a commit, and its index file. +type objects struct { + types map[string]string // Object ID to type. + dirs map[string][]string // Loose object directory ("ab") to file names. + index []byte +} + +// newObjects returns the objects and index of commit, whose root tree is +// tree. Entries are all trees and files of the commit. +func newObjects(commit, tree string, entries []treeEntry) *objects { + o := &objects{ + types: map[string]string{ + commit: "commit", + tree: "tree", + }, + dirs: map[string][]string{}, + index: encodeIndex(tree, entries), + } + for _, e := range entries { + switch e.mode & 0o170000 { + case 0o040000: + o.types[e.id] = "tree" + case 0o160000: // A submodule commit is not in this repository. + default: + o.types[e.id] = "blob" + } + } + for _, id := range slices.Sorted(maps.Keys(o.types)) { + o.dirs[id[:2]] = append(o.dirs[id[:2]], id[2:]) + } + return o +} + +// cacheTree is a node of the cache tree index extension. +type cacheTree struct { + name string // Path component; empty for the root. + id string + entries int // Number of index entries below the tree. + subs []*cacheTree +} + +// encodeIndex returns a version 2 index file for the tree root. Entries are +// the result of a recursive git ls-tree -t, so trees come before their +// entries and the rest is in path order. +// +// The checkout cannot change, so the index tells Git not to examine it. The +// index has no stat data, and every entry has the assume-unchanged flag, so +// Git does not compare the files with the index. The cache tree extension +// gives the ID of every tree, so Git does not read tree objects to compare +// the index with HEAD. +func encodeIndex(root string, entries []treeEntry) []byte { + trees := map[string]*cacheTree{ + ".": { + id: root, + }, + } + var files []treeEntry + for _, e := range entries { + dir := path.Dir(e.name) + if e.mode&0o170000 == 0o040000 { + t := &cacheTree{ + name: path.Base(e.name), + id: e.id, + } + trees[e.name] = t + trees[dir].subs = append(trees[dir].subs, t) + continue + } + files = append(files, e) + for ; dir != "."; dir = path.Dir(dir) { + trees[dir].entries++ + } + trees["."].entries++ + } + + be := binary.BigEndian + b := []byte("DIRC") + b = be.AppendUint32(b, 2) + b = be.AppendUint32(b, uint32(len(files))) + for _, e := range files { + start := len(b) + b = append(b, make([]byte, 24)...) // ctime, mtime, dev, ino + b = be.AppendUint32(b, e.mode) + b = append(b, make([]byte, 8)...) // uid, gid + b = be.AppendUint32(b, uint32(e.size)) + id, _ := hex.DecodeString(e.id) + b = append(b, id...) + const assumeValid = 0x8000 + b = be.AppendUint16(b, assumeValid|uint16(min(len(e.name), 0xfff))) + b = append(b, e.name...) + // One to eight NUL bytes end the entry at a multiple of 8 bytes. + b = append(b, make([]byte, 8-(len(b)-start)%8)...) + } + + // The extension lists the trees in preorder. Each tree is + // "\x00 \n". + var ext []byte + var appendTree func(t *cacheTree) + appendTree = func(t *cacheTree) { + ext = fmt.Appendf(ext, "%s\x00%d %d\n", t.name, t.entries, len(t.subs)) + id, _ := hex.DecodeString(t.id) + ext = append(ext, id...) + for _, sub := range t.subs { + appendTree(sub) + } + } + appendTree(trees["."]) + b = append(b, "TREE"...) + b = be.AppendUint32(b, uint32(len(ext))) + b = append(b, ext...) + + sum := sha1.Sum(b) + return append(b, sum[:]...) +} + +// memFile is a file with fixed contents. +type memFile struct { + owner + data []byte +} + +func (f memFile) Attr(*nfsv4.Request) (*nfsv4.Attrs, error) { + return f.attrs(nfsv4.TypeReg, 0o444, int64(len(f.data))), nil +} + +func (f memFile) ReadAt(_ *nfsv4.Request, p []byte, off int64) (int, error) { + return readAt(f.data, p, off) +} + +// objectsDir is .git/objects if prefix is empty, and .git/objects/ +// otherwise. +type objectsDir struct { + owner + co *Checkout + prefix string +} + +func (d *objectsDir) Attr(*nfsv4.Request) (*nfsv4.Attrs, error) { + return d.attrs(nfsv4.TypeDir, 0o555, 0), nil +} + +func (d *objectsDir) ReadDir(*nfsv4.Request) ([]nodefs.DirEntry, error) { + o := d.co.objects + names := o.dirs[d.prefix] + if d.prefix == "" { + names = slices.Sorted(maps.Keys(o.dirs)) + } + // The entries have no nodes, so that listing a directory does not + // compress all of its objects. + entries := make([]nodefs.DirEntry, len(names)) + for i, name := range names { + entries[i].Name = name + } + return entries, nil +} + +func (d *objectsDir) Lookup(_ *nfsv4.Request, name string) (nodefs.Node, error) { + o := d.co.objects + if d.prefix == "" { + if _, ok := o.dirs[name]; !ok { + return nil, fs.ErrNotExist + } + return &objectsDir{ + owner: d.owner, + co: d.co, + prefix: name, + }, nil + } + id := d.prefix + name + typ, ok := o.types[id] + if !ok { + return nil, fs.ErrNotExist + } + data, err := d.co.repo.readObject(id) + if err != nil { + return nil, err + } + // A loose object is the zlib-compressed object with a header. + var buf bytes.Buffer + zw := zlib.NewWriter(&buf) + fmt.Fprintf(zw, "%s %d\x00", typ, len(data)) + zw.Write(data) + zw.Close() + return memFile{ + owner: d.owner, + data: buf.Bytes(), + }, nil +} diff --git a/gitrepo/fs.go b/gitrepo/fs.go new file mode 100644 index 0000000..46f2c4b --- /dev/null +++ b/gitrepo/fs.go @@ -0,0 +1,251 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package gitrepo + +import ( + "fmt" + "io" + "io/fs" + "strings" + "time" + + "github.com/tailscale/nfsv4" + "github.com/tailscale/nfsv4/nodefs" +) + +var staticTime = time.Date(2009, 11, 12, 13, 14, 15, 0, time.UTC) + +var ( + _ nodefs.Dir = (*staticDir)(nil) + _ nodefs.Dir = (*gitDir)(nil) + _ nodefs.File = gitFile{} + _ nodefs.Symlink = gitLink{} +) + +// FSOptions are the options for [NewFS]. +type FSOptions struct { + // UID and GID are the owner of all files. + UID, GID uint32 + + // Checkouts are the checkouts to serve. Each must be of a different + // repository. + Checkouts []*Checkout +} + +// NewFS returns a read-only filesystem that serves each checkout of opts at +// /repos//. Serve it with an [nfsv4.Server]. +// +// Each checkout has a read-only .git directory with the loose objects of the +// commit, so that read-only Git commands work. +func NewFS(opts FSOptions) (*nodefs.FS, error) { + o := owner{ + uid: opts.UID, + gid: opts.GID, + } + repos := &staticDir{ + owner: o, + } + owners := map[string]*staticDir{} + for _, co := range opts.Checkouts { + ownerName, repoName, _ := strings.Cut(string(co.repo.name), "/") + od := owners[ownerName] + if od == nil { + od = &staticDir{ + owner: o, + } + owners[ownerName] = od + repos.entries = append(repos.entries, nodefs.DirEntry{ + Name: ownerName, + Node: od, + }) + } + if _, err := lookup(od.entries, repoName); err == nil { + return nil, fmt.Errorf("gitrepo: more than one checkout of %s", co.repo.name) + } + od.entries = append(od.entries, nodefs.DirEntry{ + Name: repoName, + Node: &gitDir{ + owner: o, + co: co, + id: co.tree, + dotGit: newDotGit(o, co), + }, + }) + } + root := &staticDir{ + owner: o, + entries: []nodefs.DirEntry{{ + Name: "repos", + Node: repos, + }}, + } + // All contents are immutable, so clients can always cache them. + return nodefs.New(root, &nodefs.Options{ + DefaultCache: nfsv4.Delegation{ + Grant: true, + }, + }), nil +} + +type owner struct { + uid, gid uint32 +} + +func (o owner) attrs(typ nfsv4.FileType, mode uint32, size int64) *nfsv4.Attrs { + return &nfsv4.Attrs{ + Type: typ, + Change: 1, // The contents never change. + Size: uint64(size), + Mode: mode, + UID: o.uid, + GID: o.gid, + ModTime: staticTime, + } +} + +// staticDir is a directory with fixed entries. It is used for the layout +// directories above each checkout and for submodules, which are empty. +type staticDir struct { + owner + entries []nodefs.DirEntry +} + +func (d *staticDir) Attr(*nfsv4.Request) (*nfsv4.Attrs, error) { + return d.attrs(nfsv4.TypeDir, 0o555, 0), nil +} + +func (d *staticDir) ReadDir(*nfsv4.Request) ([]nodefs.DirEntry, error) { + return d.entries, nil +} + +func (d *staticDir) Lookup(_ *nfsv4.Request, name string) (nodefs.Node, error) { + return lookup(d.entries, name) +} + +func lookup(entries []nodefs.DirEntry, name string) (nodefs.Node, error) { + for _, e := range entries { + if e.Name == name { + return e.Node, nil + } + } + return nil, fs.ErrNotExist +} + +// gitDir is a Git tree. +type gitDir struct { + owner + co *Checkout + id string + dotGit nodefs.Node // The synthetic .git directory; nil except at the root. +} + +func (d *gitDir) Attr(*nfsv4.Request) (*nfsv4.Attrs, error) { + return d.attrs(nfsv4.TypeDir, 0o555, 0), nil +} + +func (d *gitDir) Lookup(_ *nfsv4.Request, name string) (nodefs.Node, error) { + if d.dotGit != nil && name == ".git" { + return d.dotGit, nil + } + for _, te := range d.co.dirs[d.id] { + if te.name == name { + return d.node(te), nil + } + } + return nil, fs.ErrNotExist +} + +func (d *gitDir) ReadDir(*nfsv4.Request) ([]nodefs.DirEntry, error) { + tes := d.co.dirs[d.id] + entries := make([]nodefs.DirEntry, 0, len(tes)) + for _, te := range tes { + entries = append(entries, nodefs.DirEntry{ + Name: te.name, + Node: d.node(te), + }) + } + if d.dotGit != nil { + entries = append(entries, nodefs.DirEntry{ + Name: ".git", + Node: d.dotGit, + }) + } + return entries, nil +} + +func (d *gitDir) node(te treeEntry) nodefs.Node { + switch te.mode & 0o170000 { + case 0o040000: + return &gitDir{ + owner: d.owner, + co: d.co, + id: te.id, + } + case 0o160000: // A submodule. Like git, show an empty directory. + return &staticDir{ + owner: d.owner, + } + case 0o120000: + return gitLink{ + gitFile: gitFile{ + owner: d.owner, + repo: d.co.repo, + te: te, + }, + } + } + return gitFile{ + owner: d.owner, + repo: d.co.repo, + te: te, + } +} + +// gitFile is a Git blob. +type gitFile struct { + owner + repo *repository + te treeEntry +} + +func (f gitFile) Attr(*nfsv4.Request) (*nfsv4.Attrs, error) { + mode := uint32(0o444) + if f.te.mode&0o100 != 0 { + mode = 0o555 + } + return f.attrs(nfsv4.TypeReg, mode, f.te.size), nil +} + +func (f gitFile) ReadAt(_ *nfsv4.Request, p []byte, off int64) (int, error) { + b, err := f.repo.readObject(f.te.id) + if err != nil { + return 0, err + } + return readAt(b, p, off) +} + +func readAt(b, p []byte, off int64) (int, error) { + if off >= int64(len(b)) { + return 0, io.EOF + } + n := copy(p, b[off:]) + if off+int64(n) == int64(len(b)) { + return n, io.EOF + } + return n, nil +} + +// gitLink is a symbolic link. Its blob contains the target. +type gitLink struct { + gitFile +} + +func (l gitLink) Attr(*nfsv4.Request) (*nfsv4.Attrs, error) { + return l.attrs(nfsv4.TypeSymlink, 0o777, l.te.size), nil +} + +func (l gitLink) Readlink(*nfsv4.Request) (string, error) { + b, err := l.repo.readObject(l.te.id) + return string(b), err +} diff --git a/gitrepo/gitrepo.go b/gitrepo/gitrepo.go new file mode 100644 index 0000000..d1b9281 --- /dev/null +++ b/gitrepo/gitrepo.go @@ -0,0 +1,195 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// Package gitrepo serves immutable checkouts of Git commits over NFSv4. +// +// A long-lived [Manager] keeps one bare repository for each configured +// upstream and fetches commits when they are first checked out. [NewFS] +// serves a fixed set of checkouts, usually one set for each CI job. +package gitrepo + +import ( + "context" + "errors" + "fmt" + "path" + "path/filepath" + "strings" + + "github.com/prometheus/client_golang/prometheus" +) + +// RepoName is an "owner/repo" name. A checkout of the repository is served at +// /repos//. +type RepoName string + +// Config describes a repository that the manager can check out. +type Config struct { + // RemoteURL is the Git remote that commits are fetched from. + RemoteURL string +} + +// Manager owns the bare repositories and their shared caches. It is safe for +// concurrent use. +type Manager struct { + repos map[RepoName]*repository + metrics *metrics +} + +// metrics labels are bounded: commit hashes and paths are not labels. +type metrics struct { + fetches *prometheus.CounterVec + fetchDuration *prometheus.HistogramVec + blobReads *prometheus.CounterVec +} + +// NewManager returns a manager that keeps its bare repositories below root. +// Each repository is created when it is first checked out. +func NewManager(root string, repos map[RepoName]Config) (*Manager, error) { + root, err := filepath.Abs(root) + if err != nil { + return nil, err + } + m := &Manager{ + repos: make(map[RepoName]*repository, len(repos)), + metrics: &metrics{ + fetches: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "gomodfs_repo_fetch_total", + Help: "repository commit checkouts by result (hit, fetched, error).", + }, []string{"repo", "result"}), + fetchDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "gomodfs_repo_fetch_duration_seconds", + Help: "repository commit fetch duration.", + Buckets: prometheus.DefBuckets, + }, []string{"repo"}), + blobReads: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "gomodfs_repo_blob_read_total", + Help: "repository blob reads by result (hit, miss, error).", + }, []string{"repo", "result"}), + }, + } + for name, cfg := range repos { + if !validName(name) { + return nil, fmt.Errorf("gitrepo: invalid repository name %q; want owner/repo", name) + } + if cfg.RemoteURL == "" { + return nil, fmt.Errorf("gitrepo: repository %q has an empty remote URL", name) + } + m.repos[name] = newRepository(name, filepath.Join(root, filepath.FromSlash(string(name))+".git"), cfg.RemoteURL, m.metrics) + } + return m, nil +} + +// RegisterMetrics registers the manager's metrics with reg. +func (m *Manager) RegisterMetrics(reg prometheus.Registerer) { + reg.MustRegister(m.metrics.fetches, m.metrics.fetchDuration, m.metrics.blobReads) +} + +// Close stops the Git processes that the manager keeps running to read +// objects. After Close, the filesystems of the manager's checkouts can read +// only the files that are in the blob cache, and reads of other files fail. +// Close does not stop [Manager.Checkout], which can start new processes. Call +// Close after you stop all servers of the manager's checkouts. +func (m *Manager) Close() error { + var errs []error + for _, r := range m.repos { + errs = append(errs, r.close()) + } + return errors.Join(errs...) +} + +// Checkout is an immutable commit that a [Manager] has fetched, with the +// names and modes of all of its files. Get one with [Manager.Checkout], and +// serve it with [NewFS]. One Checkout can be in many filesystems. A Checkout +// does not hold resources, so there is nothing to release. +type Checkout struct { + repo *repository + commit string + tree string // ID of the commit's root tree. + dirs map[string][]treeEntry // Tree ID to entries. + objects *objects // For .git. +} + +// Repo returns the name of the checkout's repository. +func (c *Checkout) Repo() RepoName { + return c.repo.name +} + +// Commit returns the full SHA-1 ID of the checkout's commit. +func (c *Checkout) Commit() string { + return c.commit +} + +// Checkout returns a checkout of commit, a full SHA-1 commit ID, in repo. It +// fetches the commit from the remote if the bare repository does not contain +// it, then lists all trees of the commit with one git command: clients +// usually read most directories, and one command for each directory is much +// slower. +func (m *Manager) Checkout(ctx context.Context, repo RepoName, commit string) (*Checkout, error) { + r := m.repos[repo] + if r == nil { + return nil, fmt.Errorf("gitrepo: unknown repository %q", repo) + } + if !validSHA1(commit) { + return nil, fmt.Errorf("gitrepo: invalid full commit SHA %q", commit) + } + tree, err := r.fetch(ctx, commit) + if err == nil { + var entries []treeEntry + if entries, err = r.readTree(ctx, tree); err == nil { + return newCheckout(r, commit, tree, entries), nil + } + } + return nil, fmt.Errorf("gitrepo: checking out %s at %s: %w", repo, commit, err) +} + +// newCheckout returns a checkout of commit, whose root tree is tree. Entries +// are all trees and files of the commit, from [repository.readTree]. +func newCheckout(r *repository, commit, tree string, entries []treeEntry) *Checkout { + dirs := map[string][]treeEntry{ + tree: nil, + } + ids := map[string]string{ // Tree path to ID. + ".": tree, + } + for _, e := range entries { + if e.mode&0o170000 == 0o040000 { + ids[e.name] = e.id + // A tree that is the same as an earlier tree gets the same + // entries again. + dirs[e.id] = nil + } + parent := ids[path.Dir(e.name)] + e.name = path.Base(e.name) + dirs[parent] = append(dirs[parent], e) + } + return &Checkout{ + repo: r, + commit: commit, + tree: tree, + dirs: dirs, + objects: newObjects(commit, tree, entries), + } +} + +// validName reports whether name has the form "owner/repo", with segments +// that are usable as file and NFS names. +func validName(name RepoName) bool { + owner, repo, _ := strings.Cut(string(name), "/") + validSegment := func(s string) bool { + return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, "/\\\x00") + } + return validSegment(owner) && validSegment(repo) +} + +func validSHA1(s string) bool { + if len(s) != 40 { + return false + } + for _, c := range s { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + return false + } + } + return true +} diff --git a/gitrepo/gitrepo_test.go b/gitrepo/gitrepo_test.go new file mode 100644 index 0000000..a7101d3 --- /dev/null +++ b/gitrepo/gitrepo_test.go @@ -0,0 +1,404 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package gitrepo + +import ( + "errors" + "io/fs" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + + dto "github.com/prometheus/client_model/go" + "github.com/tailscale/nfsv4" +) + +func git(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=a", "GIT_AUTHOR_EMAIL=a@example.com", "GIT_COMMITTER_NAME=a", "GIT_COMMITTER_EMAIL=a@example.com") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %q: %v\n%s", args, err, out) + } + return strings.TrimSpace(string(out)) +} + +// upstream returns a repository with a regular file, an executable, a +// symlink, a subdirectory, and a submodule. +func upstream(t *testing.T) (dir, commit string) { + t.Helper() + dir = t.TempDir() + git(t, dir, "init", "-q", "--object-format=sha1") + for name, content := range map[string]string{ + "hello.txt": "hello\n", + "run.sh": "#!/bin/sh\n", + "sub/file.go": "package sub\n", + "dup/file.go": "package sub\n", // Same tree as sub. + } { + os.MkdirAll(filepath.Join(dir, filepath.Dir(name)), 0o755) + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + if err := os.Symlink("hello.txt", filepath.Join(dir, "link")); err != nil { + t.Skipf("symlinks unsupported: %v", err) + } + git(t, dir, "add", ".") + git(t, dir, "update-index", "--chmod=+x", "run.sh") + git(t, dir, "update-index", "--add", "--cacheinfo", "160000,"+strings.Repeat("1", 40)+",module") + git(t, dir, "commit", "-qm", "initial") + return dir, git(t, dir, "rev-parse", "HEAD") +} + +func newManager(t *testing.T, url string) *Manager { + t.Helper() + m, err := NewManager(t.TempDir(), map[RepoName]Config{ + "example/repo": { + RemoteURL: url, + }, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := m.Close(); err != nil { + t.Errorf("Close: %v", err) + } + }) + return m +} + +func walk(t *testing.T, f nfsv4.FS, p string) (nfsv4.FileHandle, *nfsv4.Attrs, error) { + t.Helper() + r := (&nfsv4.Request{}).WithContext(t.Context()) + fh, err := f.Root(r) + if err != nil { + t.Fatal(err) + } + for name := range strings.SplitSeq(p, "/") { + if name == "" { + continue + } + if fh, _, err = f.Lookup(r, fh, name); err != nil { + return nil, nil, err + } + } + attrs, err := f.GetAttr(r, fh, nfsv4.AttrMask{}) + return fh, attrs, err +} + +func list(t *testing.T, f nfsv4.FS, p string) []string { + t.Helper() + fh, _, err := walk(t, f, p) + if err != nil { + t.Fatalf("%s: %v", p, err) + } + var names []string + _, err = f.ReadDir((&nfsv4.Request{}).WithContext(t.Context()), fh, nfsv4.ReadDirArgs{}, func(e nfsv4.DirEntry) bool { + names = append(names, e.Name) + return true + }) + if err != nil { + t.Fatalf("ReadDir(%s): %v", p, err) + } + return names +} + +func read(t *testing.T, f nfsv4.FS, p string) string { + t.Helper() + fh, _, err := walk(t, f, p) + if err != nil { + t.Fatalf("%s: %v", p, err) + } + buf := make([]byte, 100) + n, eof, err := f.Read((&nfsv4.Request{}).WithContext(t.Context()), fh, 0, buf) + if err != nil || !eof { + t.Fatalf("Read(%s) = eof %v, %v", p, eof, err) + } + return string(buf[:n]) +} + +func TestFS(t *testing.T) { + dir, commit := upstream(t) + m := newManager(t, dir) + co, err := m.Checkout(t.Context(), "example/repo", commit) + if err != nil { + t.Fatal(err) + } + f, err := NewFS(FSOptions{ + UID: 1000, + GID: 1001, + Checkouts: []*Checkout{co}, + }) + if err != nil { + t.Fatal(err) + } + + for p, want := range map[string][]string{ + "": {"repos"}, + "repos": {"example"}, + "repos/example/repo": {"dup", "hello.txt", "link", "module", "run.sh", "sub", ".git"}, + "repos/example/repo/sub": {"file.go"}, + "repos/example/repo/dup": {"file.go"}, + "repos/example/repo/module": nil, + } { + if got := list(t, f, p); !slices.Equal(got, want) { + t.Errorf("list(%q) = %q; want %q", p, got, want) + } + } + if got := read(t, f, "repos/example/repo/sub/file.go"); got != "package sub\n" { + t.Errorf("sub/file.go = %q", got) + } + fh, _, _ := walk(t, f, "repos/example/repo/link") + if got, err := f.ReadLink((&nfsv4.Request{}).WithContext(t.Context()), fh); got != "hello.txt" || err != nil { + t.Errorf("ReadLink = %q, %v", got, err) + } + for p, want := range map[string]nfsv4.Attrs{ + "repos/example/repo/hello.txt": {Type: nfsv4.TypeReg, Mode: 0o444, Size: 6}, + "repos/example/repo/run.sh": {Type: nfsv4.TypeReg, Mode: 0o555, Size: 10}, + "repos/example/repo/link": {Type: nfsv4.TypeSymlink, Mode: 0o777, Size: 9}, + "repos/example/repo/sub": {Type: nfsv4.TypeDir, Mode: 0o555}, + } { + _, got, err := walk(t, f, p) + if err != nil { + t.Fatalf("%s: %v", p, err) + } + if got.Type != want.Type || got.Mode != want.Mode || got.Size != want.Size || got.UID != 1000 || got.GID != 1001 { + t.Errorf("%s: got type %v mode %o size %d owner %d:%d; want type %v mode %o size %d owner 1000:1001", + p, got.Type, got.Mode, got.Size, got.UID, got.GID, want.Type, want.Mode, want.Size) + } + } + for _, p := range []string{"repos/other", "repos/example/other", "repos/example/repo/missing"} { + if _, _, err := walk(t, f, p); !errors.Is(err, fs.ErrNotExist) && !errors.Is(err, nfsv4.ErrNoEnt) { + t.Errorf("%s: err = %v; want not exist", p, err) + } + } +} + +func TestCheckoutFetchesNewCommits(t *testing.T) { + dir, first := upstream(t) + m := newManager(t, dir) + if _, err := m.Checkout(t.Context(), "example/repo", first); err != nil { + t.Fatal(err) + } + os.WriteFile(filepath.Join(dir, "hello.txt"), []byte("second\n"), 0o644) + git(t, dir, "commit", "-qam", "second") + second := git(t, dir, "rev-parse", "HEAD") + + // Each view sees only its own commit. + for commit, want := range map[string]string{first: "hello\n", second: "second\n"} { + co, err := m.Checkout(t.Context(), "example/repo", commit) + if err != nil { + t.Fatal(err) + } + if co.Repo() != "example/repo" || co.Commit() != commit { + t.Errorf("checkout of %s: Repo, Commit = %q, %q", commit, co.Repo(), co.Commit()) + } + f, err := NewFS(FSOptions{ + Checkouts: []*Checkout{co}, + }) + if err != nil { + t.Fatal(err) + } + if got := read(t, f, "repos/example/repo/hello.txt"); got != want { + t.Errorf("%s: hello.txt = %q; want %q", commit, got, want) + } + } +} + +// fetches returns the number of checkouts of example/repo with result. +func fetches(t *testing.T, m *Manager, result string) int { + t.Helper() + var d dto.Metric + if err := m.metrics.fetches.WithLabelValues("example/repo", result).Write(&d); err != nil { + t.Fatal(err) + } + return int(d.GetCounter().GetValue()) +} + +func TestCheckoutFetchesOnce(t *testing.T) { + dir, commit := upstream(t) + // The bare repository is an empty directory in the work tree of + // upstream. Git must not use the upstream repository in its place. + root := filepath.Join(dir, "cache") + if err := os.MkdirAll(filepath.Join(root, "example", "repo.git"), 0o755); err != nil { + t.Fatal(err) + } + m, err := NewManager(root, map[RepoName]Config{ + "example/repo": { + RemoteURL: dir, + }, + }) + if err != nil { + t.Fatal(err) + } + defer m.Close() + for range 3 { + if _, err := m.Checkout(t.Context(), "example/repo", commit); err != nil { + t.Fatal(err) + } + } + if got := fetches(t, m, "fetched"); got != 1 { + t.Errorf("fetched = %d; want 1", got) + } + if got := fetches(t, m, "hit"); got != 2 { + t.Errorf("hit = %d; want 2", got) + } + if _, err := os.Stat(filepath.Join(root, "example", "repo.git", "HEAD")); err != nil { + t.Errorf("bare repository not created: %v", err) + } +} + +func TestErrors(t *testing.T) { + dir, commit := upstream(t) + m := newManager(t, dir) + for name, err := range map[string]error{ + "unknown repo": func() error { _, err := m.Checkout(t.Context(), "example/other", commit); return err }(), + "short commit": func() error { _, err := m.Checkout(t.Context(), "example/repo", commit[:7]); return err }(), + "missing commit": func() error { _, err := m.Checkout(t.Context(), "example/repo", strings.Repeat("0", 40)); return err }(), + "invalid name": func() error { + _, err := NewManager(t.TempDir(), map[RepoName]Config{"repo": {RemoteURL: dir}}) + return err + }(), + "duplicate checkout": func() error { + co, err := m.Checkout(t.Context(), "example/repo", commit) + if err != nil { + return nil + } + _, err = NewFS(FSOptions{ + Checkouts: []*Checkout{co, co}, + }) + return err + }(), + } { + if err == nil { + t.Errorf("%s: got nil error", name) + } + } +} + +// materialize copies the tree at p in f to dir. +func materialize(t *testing.T, f nfsv4.FS, p, dir string) { + t.Helper() + r := (&nfsv4.Request{}).WithContext(t.Context()) + fh, attrs, err := walk(t, f, p) + if err != nil { + t.Fatalf("%s: %v", p, err) + } + switch attrs.Type { + case nfsv4.TypeDir: + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + for _, name := range list(t, f, p) { + materialize(t, f, p+"/"+name, filepath.Join(dir, name)) + } + case nfsv4.TypeSymlink: + target, err := f.ReadLink(r, fh) + if err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, dir); err != nil { + t.Fatal(err) + } + default: + buf := make([]byte, attrs.Size) + if _, _, err := f.Read(r, fh, 0, buf); err != nil { + t.Fatalf("Read(%s): %v", p, err) + } + if err := os.WriteFile(dir, buf, os.FileMode(attrs.Mode)|0o200); err != nil { + t.Fatal(err) + } + } +} + +func TestDotGit(t *testing.T) { + src, _ := upstream(t) + os.WriteFile(filepath.Join(src, "go.mod"), []byte("module example.com/m\n\ngo 1.23\n"), 0o644) + os.WriteFile(filepath.Join(src, "main.go"), []byte("package main\n\nfunc main() {}\n"), 0o644) + git(t, src, "add", ".") + git(t, src, "commit", "-qm", "second") + commit := git(t, src, "rev-parse", "HEAD") + + co, err := newManager(t, src).Checkout(t.Context(), "example/repo", commit) + if err != nil { + t.Fatal(err) + } + f, err := NewFS(FSOptions{ + Checkouts: []*Checkout{co}, + }) + if err != nil { + t.Fatal(err) + } + dir := filepath.Join(t.TempDir(), "repo") + materialize(t, f, "repos/example/repo", dir) + + for _, tt := range []struct{ cmd, want string }{ + {"rev-parse HEAD", commit}, + {"status --porcelain", ""}, + {"log --format=%H:%s", commit + ":second"}, + {"cat-file -p HEAD:sub/file.go", "package sub"}, + {"grep -l package", "dup/file.go\nmain.go\nsub/file.go"}, + {"fsck --no-dangling", ""}, + } { + if got := git(t, dir, strings.Fields(tt.cmd)...); got != tt.want { + t.Errorf("git %s = %q; want %q", tt.cmd, got, tt.want) + } + } + // The index tells Git that the checkout is clean, so git status needs + // only the commit and its root tree: not the files, the subtrees, or + // the blobs. + dotGit := filepath.Join(t.TempDir(), "repo") + materialize(t, f, "repos/example/repo/.git", filepath.Join(dotGit, ".git")) + keep := []string{commit, git(t, src, "rev-parse", "HEAD^{tree}")} + objs, _ := filepath.Glob(filepath.Join(dotGit, ".git", "objects", "*", "*")) + for _, obj := range objs { + if !slices.Contains(keep, filepath.Base(filepath.Dir(obj))+filepath.Base(obj)) { + os.Remove(obj) + } + } + if got := git(t, dotGit, "status", "--porcelain"); got != "" { + t.Errorf("git status without files and subtrees = %q; want clean", got) + } + + // Go builds with VCS stamping need git status and git log to work. + build := exec.Command("go", "build", "-buildvcs=true", "-o", filepath.Join(t.TempDir(), "m"), ".") + build.Dir = dir + build.Env = append(os.Environ(), "GOFLAGS=", "GOWORK=off") + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("go build: %v\n%s", err, out) + } +} + +func TestClose(t *testing.T) { + dir, commit := upstream(t) + m := newManager(t, dir) + co, err := m.Checkout(t.Context(), "example/repo", commit) + if err != nil { + t.Fatal(err) + } + f, err := NewFS(FSOptions{ + Checkouts: []*Checkout{co}, + }) + if err != nil { + t.Fatal(err) + } + read(t, f, "repos/example/repo/hello.txt") // Starts the cat-file process. + if err := m.Close(); err != nil { + t.Fatal(err) + } + // Read a file that is not in the blob cache. + fh, _, err := walk(t, f, "repos/example/repo/run.sh") + if err != nil { + t.Fatal(err) + } + if _, _, err := f.Read((&nfsv4.Request{}).WithContext(t.Context()), fh, 0, make([]byte, 100)); err == nil { + t.Error("Read after Close succeeded") + } +} diff --git a/gitrepo/repository.go b/gitrepo/repository.go new file mode 100644 index 0000000..297fa2e --- /dev/null +++ b/gitrepo/repository.go @@ -0,0 +1,227 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package gitrepo + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "sync" + "time" + + "github.com/tailscale/gomodfs/internal/lru" +) + +// blobCacheSize is the maximum number of blob bytes that each repository keeps +// in memory. Blobs are content-addressed, so all checkouts of a repository +// share the cache. +const blobCacheSize = 64 << 20 + +// repository is one bare Git repository. +type repository struct { + name RepoName + dir string + url string + metrics *metrics + + fetchMu sync.Mutex // Serializes init and fetch. + inited bool + + blobsMu sync.Mutex + blobs lru.Cache[string, []byte] + + catMu sync.Mutex // Serializes reads with cat. + cat *catFile // nil until the first read, or after an error. + closed bool // Set by close; later reads fail. +} + +func newRepository(name RepoName, dir, url string, m *metrics) *repository { + r := &repository{ + name: name, + dir: dir, + url: url, + metrics: m, + } + r.blobs.MaxSize = blobCacheSize + r.blobs.EntrySize = func(_ string, b []byte) int64 { return int64(len(b)) + 1 } + return r +} + +// command returns a git command in the repository. It sets GIT_DIR, so that +// Git does not look for a repository in the parent directories if r.dir is +// not a repository. +func (r *repository) command(ctx context.Context, args ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Dir = r.dir + cmd.Env = append(os.Environ(), "GIT_DIR="+r.dir) + return cmd +} + +// fetch makes sure that the repository contains commit, and returns the ID +// of the commit's root tree. +func (r *repository) fetch(ctx context.Context, commit string) (string, error) { + // Look for the commit before the lock, so that checkouts of commits + // that the repository contains do not wait for fetches of other + // commits. Before init, the repository can be missing, and the + // command fails. + if tree, err := r.rootTree(ctx, commit); err == nil { + r.metrics.fetches.WithLabelValues(string(r.name), "hit").Inc() + return tree, nil + } + r.fetchMu.Lock() + defer r.fetchMu.Unlock() + if !r.inited { + // Init is safe to run on an existing repository. It fails if the + // existing repository does not use SHA-1. + cmd := exec.CommandContext(ctx, "git", "init", "--bare", "--quiet", "--object-format=sha1", r.dir) + if err := run(cmd); err != nil { + return "", err + } + r.inited = true + } + // Look again: the repository can already exist, or another fetch can + // have added the commit while this one waited for the lock. + if tree, err := r.rootTree(ctx, commit); err == nil { + r.metrics.fetches.WithLabelValues(string(r.name), "hit").Inc() + return tree, nil + } + start := time.Now() + // The ref keeps the commit's objects in the repository and gives later + // fetches a base to negotiate from. + err := run(r.command(ctx, "fetch", "--quiet", "--no-tags", r.url, commit+":refs/gomodfs/"+commit)) + var tree string + if err == nil { + tree, err = r.rootTree(ctx, commit) + } + result := "fetched" + if err != nil { + result = "error" + } + r.metrics.fetches.WithLabelValues(string(r.name), result).Inc() + r.metrics.fetchDuration.WithLabelValues(string(r.name)).Observe(time.Since(start).Seconds()) + return tree, err +} + +func (r *repository) rootTree(ctx context.Context, commit string) (string, error) { + out, err := r.command(ctx, "rev-parse", "--verify", "--quiet", "--end-of-options", commit+"^{commit}^{tree}").Output() + if err != nil { + return "", fmt.Errorf("commit not found: %w", err) + } + return strings.TrimSpace(string(out)), nil +} + +// treeEntry is one entry of a Git tree object. +type treeEntry struct { + name string + mode uint32 // Git mode, such as 0o100644. + id string // Object ID. + size int64 // Blob size; zero for trees and submodules. +} + +// readTree lists the tree id and all of its subtrees. Names are paths from +// id. Each tree comes before its entries. +func (r *repository) readTree(ctx context.Context, id string) ([]treeEntry, error) { + out, err := r.command(ctx, "ls-tree", "-r", "-t", "-l", "-z", id).Output() + if err != nil { + return nil, fmt.Errorf("reading tree %s: %w", id, err) + } + var entries []treeEntry + for rec := range bytes.SplitSeq(out, []byte{0}) { + if len(rec) == 0 { + continue + } + // Each record is " \t". + meta, name, ok := strings.Cut(string(rec), "\t") + f := strings.Fields(meta) + if !ok || len(f) != 4 { + return nil, fmt.Errorf("reading tree %s: malformed entry %q", id, rec) + } + mode, err := strconv.ParseUint(f[0], 8, 32) + if err != nil { + return nil, fmt.Errorf("reading tree %s: malformed mode %q", id, f[0]) + } + size, _ := strconv.ParseInt(f[3], 10, 64) // The size is "-" for trees and submodules. + entries = append(entries, treeEntry{ + name: name, + mode: uint32(mode), + id: f[2], + size: size, + }) + } + return entries, nil +} + +// readObject returns the contents of the object id. +func (r *repository) readObject(id string) ([]byte, error) { + r.blobsMu.Lock() + b, ok := r.blobs.GetOk(id) + r.blobsMu.Unlock() + if ok { + r.metrics.blobReads.WithLabelValues(string(r.name), "hit").Inc() + return b, nil + } + b, err := r.catFile(id) + if err != nil { + r.metrics.blobReads.WithLabelValues(string(r.name), "error").Inc() + return nil, fmt.Errorf("reading object %s: %w", id, err) + } + r.metrics.blobReads.WithLabelValues(string(r.name), "miss").Inc() + if len(b) < blobCacheSize { + r.blobsMu.Lock() + r.blobs.Set(id, b) + r.blobsMu.Unlock() + } + return b, nil +} + +// catFile reads the object id with the repository's cat-file process. It +// starts the process if necessary. +func (r *repository) catFile(id string) ([]byte, error) { + r.catMu.Lock() + defer r.catMu.Unlock() + if r.closed { + return nil, errors.New("gitrepo: manager is closed") + } + if r.cat == nil { + cat, err := startCatFile(r.dir) + if err != nil { + return nil, err + } + r.cat = cat + } + b, err := r.cat.read(id) + if err != nil { + // The output stream is in an unknown state. Start a new process + // for the next read. + r.cat.close() + r.cat = nil + } + return b, err +} + +// close stops the repository's cat-file process. Later reads fail. +func (r *repository) close() error { + r.catMu.Lock() + defer r.catMu.Unlock() + r.closed = true + if r.cat == nil { + return nil + } + err := r.cat.close() + r.cat = nil + return err +} + +func run(cmd *exec.Cmd) error { + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("git %s: %w: %s", cmd.Args[1], err, bytes.TrimSpace(out)) + } + return nil +} diff --git a/go.mod b/go.mod index 538a63e..16be2ee 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/tailscale/gomodfs -go 1.25.1 +go 1.27.1 require ( github.com/bradfitz/parentdeath v0.0.0-20260315043412-764506aeb900 @@ -12,6 +12,7 @@ require ( github.com/prometheus/client_golang v1.23.0 github.com/prometheus/client_model v0.6.2 github.com/rasky/go-xdr v0.0.0-20170124162913-1a41d1a06c93 + github.com/tailscale/nfsv4 v0.0.0-20261009101149-867c8548497d github.com/willscott/go-nfs v0.0.3 github.com/willscott/go-nfs-client v0.0.0-20251022144359-801f10d98886 github.com/winfsp/go-winfsp v1.0.5 diff --git a/go.sum b/go.sum index 10c22c2..dfc9a21 100644 --- a/go.sum +++ b/go.sum @@ -92,6 +92,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tailscale/nfsv4 v0.0.0-20261009101149-867c8548497d h1:8H9Rl9b+08ROeriFzVBHJMApmMC9fMWPryBshfL0qYQ= +github.com/tailscale/nfsv4 v0.0.0-20261009101149-867c8548497d/go.mod h1:89YVcbEYIcLQXV4OrVnJII/b68Rf+29B72BlulDuxhc= github.com/willscott/go-nfs v0.0.3 h1:Z5fHVxMsppgEucdkKBN26Vou19MtEM875NmRwj156RE= github.com/willscott/go-nfs v0.0.3/go.mod h1:VhNccO67Oug787VNXcyx9JDI3ZoSpqoKMT/lWMhUIDg= github.com/willscott/go-nfs-client v0.0.0-20251022144359-801f10d98886 h1:DtrBtkgTJk2XGt4T7eKdKVkd9A5NCevN2e4inLXtsqA= diff --git a/testing/ci/ci_test.go b/testing/ci/ci_test.go index cf1b321..0b54a01 100644 --- a/testing/ci/ci_test.go +++ b/testing/ci/ci_test.go @@ -5,19 +5,25 @@ package ci import ( "bytes" + "debug/buildinfo" "encoding/json" "flag" "net/http" "os" + "os/exec" "path/filepath" + "runtime/debug" + "slices" + "strings" "testing" "github.com/tailscale/gomodfs" ) var ( - runVerifyUsed = flag.Bool("verify-used", false, "if set, runs TestVerifyUsed") - runRelativeOpen = flag.Bool("relative-open", false, "if set, runs TestRelativeOpen against the mounted $GOMODCACHE") + runVerifyUsed = flag.Bool("verify-used", false, "if set, runs TestVerifyUsed") + runRelativeOpen = flag.Bool("relative-open", false, "if set, runs TestRelativeOpen against the mounted $GOMODCACHE") + runRepositoryMount = flag.Bool("repository-mount", false, "verify the CI NFS repository mount") ) // TestRelativeOpen opens files in the mounted module cache by paths relative @@ -84,3 +90,59 @@ func TestVerifyUsed(t *testing.T) { // TODO: gracefully shut down the server? meh. CI will clean up. } + +func TestRepositoryMount(t *testing.T) { + if !*runRepositoryMount { + t.Skip("only runs in CI with -repository-mount") + } + dir := os.Getenv("GOMODFS_REPO") + if dir == "" { + t.Fatal("GOMODFS_REPO must be the mounted fixture") + } + // The example server's fixed owner needn't match the CI runner. + t.Setenv("GIT_CONFIG_COUNT", "1") + t.Setenv("GIT_CONFIG_KEY_0", "safe.directory") + t.Setenv("GIT_CONFIG_VALUE_0", dir) + git := func(args ...string) string { + t.Helper() + cmd := exec.CommandContext(t.Context(), "git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %q: %v\n%s", args, err, out) + } + return strings.TrimSpace(string(out)) + } + if got := git("status", "--porcelain"); got != "" { + t.Errorf("dirty checkout: %s", got) + } + if got := git("log", "--format=%s"); got != "NFS repository fixture" { + t.Errorf("git log = %q", got) + } + info, err := os.Stat(filepath.Join(dir, "run.sh")) + if err != nil || info.Mode()&0111 == 0 { + t.Errorf("mounted executable mode: %v, %v", info, err) + } + if target, err := os.Readlink(filepath.Join(dir, "main.link")); err != nil || target != "main.go" { + t.Errorf("mounted symlink = %q, %v", target, err) + } + if f, err := os.OpenFile(filepath.Join(dir, "main.go"), os.O_WRONLY|os.O_APPEND, 0); err == nil { + f.Close() + t.Error("opened main.go for writing on read-only checkout") + } + + binary := filepath.Join(t.TempDir(), "fixture.exe") + cmd := exec.CommandContext(t.Context(), "go", "build", "-buildvcs=true", "-o", binary, ".") + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOWORK=off", "GOFLAGS=", "GOTOOLCHAIN=local") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("go build: %v\n%s", err, out) + } + bi, err := buildinfo.ReadFile(binary) + if err != nil { + t.Fatal(err) + } + if !slices.Contains(bi.Settings, debug.BuildSetting{Key: "vcs.modified", Value: "false"}) { + t.Errorf("build settings %v; want vcs.modified=false", bi.Settings) + } +} diff --git a/testing/nfsmount/nfsmount.go b/testing/nfsmount/nfsmount.go index 232aba2..e868da8 100644 --- a/testing/nfsmount/nfsmount.go +++ b/testing/nfsmount/nfsmount.go @@ -9,6 +9,7 @@ package main import ( "context" + "flag" "fmt" "log" "net" @@ -21,16 +22,23 @@ import ( ) func main() { + repo := flag.Bool("repo", false, "mount the Git checkout that startgomodfs serves over NFSv4.1 instead of the module cache") + flag.Parse() if os.Getenv("CI") != "true" { log.Fatalf("startgomodfs is only intended to be run in CI") } + export, mountName, envName, port := "/gomodfs", "gomodfs-mnt", "GOMODCACHE", 2049 + if *repo { + export, mountName, envName, port = "/repos/example/repo", "gomodfs-repo-mnt", "GOMODFS_REPO", 2050 + } + var cmd *exec.Cmd var mntDir string var machineIP = "127.0.0.1" useTempMnt := func() { - mntDir = filepath.Join(os.TempDir(), "gomodfs-mnt") + mntDir = filepath.Join(os.TempDir(), mountName) if err := os.MkdirAll(mntDir, 0755); err != nil { log.Fatalf("creating mount dir %s: %v", mntDir, err) } @@ -66,7 +74,7 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var d net.Dialer - conn, err := d.DialContext(ctx, "tcp", "127.0.0.1:2049") + conn, err := d.DialContext(ctx, "tcp", fmt.Sprintf("127.0.0.1:%d", port)) if err != nil { return fmt.Errorf("NFS dial error: %v", err) } @@ -91,18 +99,26 @@ func main() { mntDir) case "linux": useTempMnt() + opts := linuxNFSMountOpts(2049) + if *repo { + opts = fmt.Sprintf("vers=4.1,port=%d,ro", port) + } cmd = exec.Command("sudo", "/usr/bin/mount", "-t", "nfs", - "-o", linuxNFSMountOpts(2049), - machineIP+":/gomodfs", + "-o", opts, + machineIP+":"+export, mntDir, ) case "darwin": useTempMnt() + opts := darwinNFSMountOpts(2049) + if *repo { + opts = fmt.Sprintf("vers=4.1,port=%d,rdonly,rsize=1048576", port) + } cmd = exec.Command("/sbin/mount", "-t", "nfs", - "-o", darwinNFSMountOpts(2049), - machineIP+":/gomodfs", + "-o", opts, + machineIP+":"+export, mntDir, ) default: @@ -163,11 +179,11 @@ func main() { if runtime.GOOS == "windows" && strings.HasSuffix(mcDir, ":") { mcDir += "\\" } - err := appendFile(e, fmt.Appendf(nil, "GOMODCACHE=%s\n", mcDir), 0644) + err := appendFile(e, fmt.Appendf(nil, "%s=%s\n", envName, mcDir), 0644) if err != nil { - log.Fatalf("writing GOMODFS to GITHUB_ENV file %q: %v", e, err) + log.Fatalf("writing %s to GITHUB_ENV file %q: %v", envName, e, err) } - log.Printf("set env GOMODCACHE=%s", mcDir) + log.Printf("set env %s=%s", envName, mcDir) } } diff --git a/testing/startgomodfs/fixture.go b/testing/startgomodfs/fixture.go new file mode 100644 index 0000000..a388976 --- /dev/null +++ b/testing/startgomodfs/fixture.go @@ -0,0 +1,59 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +func createRepoFixture(dir string) (string, error) { + git := func(args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GIT_AUTHOR_DATE=2024-01-02T03:04:05Z", "GIT_COMMITTER_DATE=2024-01-02T03:04:05Z") + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("git %q: %w: %s", args, err, out) + } + return strings.TrimSpace(string(out)), nil + } + for _, args := range [][]string{ + {"init", "--initial-branch=main", "--object-format=sha1"}, + {"config", "user.name", "NFS CI"}, + {"config", "user.email", "nfs-ci@example.com"}, + } { + if _, err := git(args...); err != nil { + return "", err + } + } + for name, content := range map[string]string{ + "go.mod": "module example.com/nfsfixture\n\ngo 1.23.0\n", + "main.go": "package main\n\nfunc main() { println(\"nfs-repository-fixture\") }\n", + "run.sh": "#!/bin/sh\necho nfs-repository-fixture\n", + } { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil { + return "", err + } + } + if err := os.Chmod(filepath.Join(dir, "run.sh"), 0755); err != nil { + return "", err + } + if err := os.Symlink("main.go", filepath.Join(dir, "main.link")); err != nil { + return "", err + } + for _, args := range [][]string{ + {"add", "."}, + {"update-index", "--chmod=+x", "run.sh"}, + {"-c", "commit.gpgsign=false", "commit", "-m", "NFS repository fixture"}, + } { + if _, err := git(args...); err != nil { + return "", err + } + } + return git("rev-parse", "HEAD") +} diff --git a/testing/startgomodfs/startgomodfs.go b/testing/startgomodfs/startgomodfs.go index 9d4fc03..17bdaf9 100644 --- a/testing/startgomodfs/startgomodfs.go +++ b/testing/startgomodfs/startgomodfs.go @@ -16,6 +16,7 @@ import ( "net/http" "os" "os/exec" + "path/filepath" "reflect" "runtime" "syscall" @@ -53,6 +54,26 @@ func main() { } } + // The Windows NFS client does not support NFSv4.1, so only Linux and + // macOS mount the Git checkout. + if runtime.GOOS != "windows" { + dir, err := os.MkdirTemp("", "gomodfs-repo-fixture-") + if err != nil { + log.Fatal(err) + } + repoDir := filepath.Join(dir, "example", "repo") + if err := os.MkdirAll(repoDir, 0755); err != nil { + log.Fatal(err) + } + commit, err := createRepoFixture(repoDir) + if err != nil { + log.Fatal(err) + } + // nfsmount can mount from an IP address that is not a loopback + // address, so listen on all interfaces, like -nfs. + cmd.Args = append(cmd.Args, "-repo=file://"+filepath.ToSlash(repoDir), "-commit="+commit, "-repo-nfs=:2050") + } + cmd.Stdout = f cmd.Stderr = f if runtime.GOOS != "windows" {