Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <remote> -commit <full-SHA-1>` serves a read-only checkout of the
commit at `/repos/<owner>/<repo>` 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/<owner>/<repo> /mnt/checkout
# macOS
mount -t nfs -o vers=4.1,port=2050,rdonly,rsize=1048576 127.0.0.1:/repos/<owner>/<repo> /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
Expand Down
85 changes: 83 additions & 2 deletions cmd/gomodfs/gomodfs-main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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/<owner>/<repo> over NFSv4.1. The <owner> and <repo> 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")

Expand All @@ -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()

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
37 changes: 37 additions & 0 deletions cmd/gomodfs/gomodfs-main_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
79 changes: 79 additions & 0 deletions gitrepo/catfile.go
Original file line number Diff line number Diff line change
@@ -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 "<id> <type> <size>\n<contents>\n", or
// "<id> 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()
}
Loading
Loading