From ebec194b1a267c41005c5c17ec9b6b7b57ed64c9 Mon Sep 17 00:00:00 2001 From: Tom Proctor Date: Fri, 9 Oct 2026 12:06:23 +0000 Subject: [PATCH] gitrepo: serve immutable Git checkouts over NFSv4.1 This commit adds a new experimental package gitrepo, which serves read-only checkouts of git commits over NFSv4.1 using github.com/tailscale/nfsv4. It is for ephemeral workloads like CI VMs that know which commits they need before they start. A long-lived Manager keeps one bare repository per configured remote, under a root directory. The first checkout of a repository runs "git init --bare", and each checkout of a commit that is not yet in the repository fetches it by SHA into refs/gomodfs/. NewFS serves a fixed set of checkouts at /repos// as a nodefs filesystem, which the caller serves with its own nfsv4.Server. Each view needs its own server, so jobs on one host cannot see each other's checkouts and we don't need to include the SHA-1 as part of the checkout path. Each repository reads objects with one long-running "git cat-file --batch" process for fast content reads. All contents are immutable, so every object grants read delegations. Each checkout has a read-only .git directory, so that read-only Git commands and Go's VCS stamping work. It contains HEAD, a shallow file, an index generated from the tree, and the loose objects of the commit and no other commit. cmd/gomodfs gets -repo, -commit, and -repo-nfs flags to serve one checkout next to the module cache as a demo, and CI mounts that checkout on Linux and macOS. Updates tailscale/corp#47555 Signed-off-by: Tom Proctor --- .github/workflows/test.yml | 10 + README.md | 17 ++ cmd/gomodfs/gomodfs-main.go | 85 +++++- cmd/gomodfs/gomodfs-main_test.go | 37 +++ gitrepo/catfile.go | 79 ++++++ gitrepo/dotgit.go | 280 +++++++++++++++++++ gitrepo/fs.go | 251 +++++++++++++++++ gitrepo/gitrepo.go | 195 +++++++++++++ gitrepo/gitrepo_test.go | 404 +++++++++++++++++++++++++++ gitrepo/repository.go | 227 +++++++++++++++ go.mod | 3 +- go.sum | 2 + testing/ci/ci_test.go | 66 ++++- testing/nfsmount/nfsmount.go | 34 ++- testing/startgomodfs/fixture.go | 59 ++++ testing/startgomodfs/startgomodfs.go | 21 ++ 16 files changed, 1756 insertions(+), 14 deletions(-) create mode 100644 cmd/gomodfs/gomodfs-main_test.go create mode 100644 gitrepo/catfile.go create mode 100644 gitrepo/dotgit.go create mode 100644 gitrepo/fs.go create mode 100644 gitrepo/gitrepo.go create mode 100644 gitrepo/gitrepo_test.go create mode 100644 gitrepo/repository.go create mode 100644 testing/startgomodfs/fixture.go 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" {