Skip to content

Commit 0ed46d9

Browse files
committed
nfs: map NFS handle back to files after a restart
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
1 parent 7a11ba8 commit 0ed46d9

File tree

19 files changed

+698
-45
lines changed

19 files changed

+698
-45
lines changed

go.mod

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
module github.com/tailscale/gomodfs
22

3-
go 1.23.4
3+
go 1.24.4
4+
5+
toolchain go1.24.6
46

57
require (
68
github.com/go-git/go-billy/v5 v5.6.2
@@ -13,6 +15,7 @@ require (
1315
golang.org/x/mod v0.26.0
1416
golang.org/x/net v0.42.0
1517
golang.org/x/sync v0.16.0
18+
tailscale.com v1.86.4
1619
)
1720

1821
require (

go.sum

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZ
99
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
1010
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
1111
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
12-
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
1312
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
13+
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
14+
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
1415
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
1516
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
1617
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
@@ -21,6 +22,8 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj
2122
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
2223
github.com/go-git/go-git/v5 v5.16.2 h1:fT6ZIOjE5iEnkzKyxTHK1W4HGAsPhqEqiSAssSO77hM=
2324
github.com/go-git/go-git/v5 v5.16.2/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
25+
github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 h1:F8d1AJ6M9UQCavhwmO6ZsrYLfG8zVFWfEfMS2MXPkSY=
26+
github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
2427
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
2528
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
2629
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -52,8 +55,9 @@ github.com/pjbgf/sha1cd v0.4.0 h1:NXzbL1RvjTUi6kgYZCX3fPwwl27Q1LJndxtUDVfJGRY=
5255
github.com/pjbgf/sha1cd v0.4.0/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
5356
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
5457
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
55-
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
5658
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
59+
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
60+
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
5761
github.com/rasky/go-xdr v0.0.0-20170124162913-1a41d1a06c93 h1:UVArwN/wkKjMVhh2EQGC0tEc1+FqiLlvYXY5mQ2f8Wg=
5862
github.com/rasky/go-xdr v0.0.0-20170124162913-1a41d1a06c93/go.mod h1:Nfe4efndBz4TibWycNE+lqyJZiMX4ycx+QKV8Ta0f/o=
5963
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -76,8 +80,8 @@ go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4
7680
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
7781
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
7882
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
79-
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
80-
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
83+
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs=
84+
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo=
8185
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
8286
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
8387
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
@@ -98,3 +102,5 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
98102
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
99103
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
100104
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
105+
tailscale.com v1.86.4 h1:KHAmLyzVn50t2P5877wohBU0UPVeIMHC9XDzRw4Ycz4=
106+
tailscale.com v1.86.4/go.mod h1:Lm8dnzU2i/Emw15r6sl3FRNp/liSQ/nYw6ZSQvIdZ1M=

gomodfs.go

Lines changed: 207 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ import (
88
"bytes"
99
"cmp"
1010
"context"
11+
"crypto/sha256"
1112
"encoding/json"
1213
"errors"
1314
"fmt"
1415
"io"
16+
"iter"
1517
"log"
1618
"math"
1719
"net"
@@ -37,6 +39,8 @@ import (
3739
"golang.org/x/mod/module"
3840
"golang.org/x/mod/sumdb/dirhash"
3941
"golang.org/x/sync/singleflight"
42+
"tailscale.com/types/result"
43+
"tailscale.com/util/testenv"
4044
)
4145

4246
const (
@@ -57,10 +61,23 @@ type FS struct {
5761
// It should not have a trailing slash.
5862
ModuleProxyURL string
5963

64+
Logf func(format string, args ...any) // if non-nil, alternate logger to use
65+
6066
Verbose bool
6167

62-
mu sync.RWMutex
63-
zipRootCache map[store.ModuleVersion]modHandleCacheEntry
68+
mu sync.RWMutex
69+
zipRootCache map[store.ModuleVersion]modHandleCacheEntry
70+
modVerHash map[modVerHash]store.ModuleVersion // nil until first used
71+
pathHashTarget map[pathHash]handleTarget // nil until first used
72+
}
73+
74+
func hashModVersion(mv store.ModuleVersion) (ret modVerHash) {
75+
s := sha256.New()
76+
io.WriteString(s, mv.Module)
77+
io.WriteString(s, "\x00")
78+
io.WriteString(s, mv.Version)
79+
s.Sum(ret[:0])
80+
return
6481
}
6582

6683
type modHandleCacheEntry struct {
@@ -147,6 +164,21 @@ func (fs *FS) downloadInfoFile(ctx context.Context, mv store.ModuleVersion) (_ [
147164
return vi.([]byte), nil
148165
}
149166

167+
func (fs *FS) netLogf(format string, arg ...any) {
168+
if testenv.InTest() && fs.Client != nil {
169+
format = "[fake-network] " + format
170+
}
171+
fs.logf(format, arg...)
172+
}
173+
174+
func (fs *FS) logf(format string, arg ...any) {
175+
if fs.Logf != nil {
176+
fs.Logf(format, arg...)
177+
} else {
178+
log.Printf(format, arg...)
179+
}
180+
}
181+
150182
func (fs *FS) downloadZip(ctx context.Context, mv store.ModuleVersion) (store.ModHandle, error) {
151183
baseURL, err := fs.modURLBase(mv)
152184
if err != nil {
@@ -163,7 +195,7 @@ func (fs *FS) downloadZip(ctx context.Context, mv store.ModuleVersion) (store.Mo
163195
return nil, fmt.Errorf("failed to download %q: %w", urlStr, err)
164196
}
165197
download[ext] = data
166-
log.Printf("Downloaded %d bytes from %v", len(data), urlStr)
198+
fs.netLogf("Downloaded %d bytes from %v", len(data), urlStr)
167199
}
168200

169201
zr, err := zip.NewReader(bytes.NewReader(download["zip"]), int64(len(download["zip"])))
@@ -258,6 +290,12 @@ func (fs *FS) setZipRootCache(mv store.ModuleVersion, h store.ModHandle) {
258290
if fs.zipRootCache == nil {
259291
fs.zipRootCache = make(map[store.ModuleVersion]modHandleCacheEntry)
260292
}
293+
if fs.modVerHash != nil {
294+
// If it's already initialized, add new stuff to it.
295+
// Otherwise if it's nil, it'll get populated later.
296+
fs.modVerHash[hashModVersion(mv)] = mv
297+
fs.addModuleVersionPathsLocked(mv)
298+
}
261299
now := new(atomic.Int64)
262300
now.Store(time.Now().Unix())
263301
fs.zipRootCache[mv] = modHandleCacheEntry{
@@ -374,6 +412,158 @@ func (fs *FS) getZiphash(ctx context.Context, mv store.ModuleVersion) (data []by
374412
return fs.Store.GetZipHash(ctx, zr)
375413
}
376414

415+
func (fs *FS) walkStoreModulePaths(ctx context.Context, mh store.ModHandle) iter.Seq[result.Of[string]] {
416+
return func(yield func(result.Of[string]) bool) {
417+
var doDir func(string) bool // recursive dir walker, called with "" (root) or "path/to/file-or-dir"
418+
doDir = func(path string) bool {
419+
if !yield(result.Value(path)) {
420+
return false
421+
}
422+
ents, err := fs.Store.Readdir(ctx, mh, path)
423+
if err != nil {
424+
yield(result.Error[string](err))
425+
return false
426+
}
427+
for _, ent := range ents {
428+
sub := ent.Name
429+
if path != "" {
430+
sub = path + "/" + ent.Name
431+
}
432+
if ent.Mode.IsDir() {
433+
if !doDir(sub) {
434+
return false
435+
}
436+
} else {
437+
if !yield(result.Value(sub)) {
438+
return false
439+
}
440+
}
441+
}
442+
return true
443+
}
444+
doDir("")
445+
}
446+
}
447+
448+
// modVerHash is SHA256(store.ModuleVersion).
449+
type modVerHash [sha256.Size]byte
450+
451+
func (h modVerHash) IsZero() bool { return h == modVerHash{} }
452+
453+
// pathHash is SHA256(either abs path or path-with-a-zp)
454+
type pathHash [sha256.Size]byte
455+
456+
var (
457+
cdFileInfo = mkWellKnownPathHash("info")
458+
cdFileMod = mkWellKnownPathHash("mod")
459+
cdFileZiphash = mkWellKnownPathHash("ziphash")
460+
)
461+
462+
func mkWellKnownPathHash(s string) pathHash {
463+
var h pathHash
464+
copy(h[:], s)
465+
return h
466+
}
467+
468+
// returns (v, nil) on hit, (zero, nil) on miss, or (zero, err) on error.
469+
func (fs *FS) moduleVersionWithHash(mvh modVerHash) (mv store.ModuleVersion, err error) {
470+
var zero store.ModuleVersion
471+
472+
fs.mu.Lock()
473+
defer fs.mu.Unlock()
474+
if err := fs.initHandleMapsLocked(); err != nil {
475+
fs.logf("initHandleMapsLocked error: %v", err)
476+
return zero, err
477+
}
478+
return fs.modVerHash[mvh], nil
479+
}
480+
481+
func (fs *FS) initHandleMapsLocked() error {
482+
if fs.modVerHash != nil {
483+
return nil
484+
}
485+
486+
mvs, err := fs.Store.CachedModules(context.TODO())
487+
if err != nil {
488+
return fmt.Errorf("CachedModules: %w", err)
489+
}
490+
491+
fs.modVerHash = make(map[modVerHash]store.ModuleVersion)
492+
fs.pathHashTarget = make(map[pathHash]handleTarget)
493+
494+
for _, mv := range mvs {
495+
fs.modVerHash[hashModVersion(mv)] = mv
496+
fs.addModuleVersionPathsLocked(mv)
497+
}
498+
499+
for _, path := range []string{
500+
"cache",
501+
"cache/download",
502+
statusFile,
503+
} {
504+
fs.addPathHashTargetLocked(path)
505+
}
506+
for _, goos := range tsGoGeese {
507+
for _, goarch := range tsGoGoarches {
508+
fs.addPathHashTargetLocked(fmt.Sprintf("tsgo-%s-%s", goos, goarch))
509+
}
510+
}
511+
512+
return nil
513+
}
514+
515+
// should return [staleErr] on non-I/O-error-related miss.
516+
func (fs *FS) handleTargetWithPathHash(ph pathHash) (handleTarget, error) {
517+
var zero handleTarget
518+
519+
fs.mu.Lock()
520+
defer fs.mu.Unlock()
521+
522+
if err := fs.initHandleMapsLocked(); err != nil {
523+
return zero, err
524+
}
525+
526+
ht, ok := fs.pathHashTarget[ph]
527+
if !ok {
528+
return zero, staleErr(fmt.Errorf("unknown path hash %02x", ph))
529+
}
530+
return ht, nil
531+
}
532+
533+
func (fs *FS) addModuleVersionPathsLocked(mv store.ModuleVersion) {
534+
// Make "cache/download/github.com/foo/bar/@v/v1.2.3.foo" and then walk up
535+
// its directories to add them to the cache. We don't care about the "foo"
536+
// final component; just the directories above it.
537+
fs.addParentPathsLocked(cdPath(mv, "foo"))
538+
539+
// Now add the zip root directories above the one with the "@" in it.
540+
// e.g. "github.com/!azure/azure-sdk-for-go/sdk" components above
541+
// "github.com/!azure/azure-sdk-for-go/sdk/azcore@v1.11.0"
542+
fs.addParentPathsLocked(zipRoot(mv))
543+
}
544+
545+
func (fs *FS) addParentPathsLocked(s string) {
546+
for {
547+
s = filepath.Dir(s)
548+
switch s {
549+
case ".", "cache", "cache/download":
550+
// Stop once you see any of these.
551+
// They're all well-known already, so we're done.
552+
return
553+
}
554+
fs.addPathHashTargetLocked(s)
555+
}
556+
}
557+
558+
func (fs *FS) addPathHashTargetLocked(path string) {
559+
h := mkPathHash(path)
560+
ht := handleTarget{path: path}
561+
if path != "" {
562+
ht.segs = strings.Split(path, "/")
563+
}
564+
fs.pathHashTarget[h] = ht
565+
}
566+
377567
type moduleNameNode struct {
378568
fs.Inode
379569
paths []string
@@ -704,6 +894,20 @@ func (n *pathUnderZipRoot) Read(ctx context.Context, h fs.FileHandle, dest []byt
704894
return fuse.ReadResultData(n.fileContent[int(off):end]), 0
705895
}
706896

897+
// cdPath returns the "cache/download/go4.org/mem/@v/v0.0.0-20240501181205-ae6ca9944745.foo" file.
898+
// It returns an invalid path if mv is invalid or malformed.
899+
func cdPath(mv store.ModuleVersion, ext string) string {
900+
escMod, _ := module.EscapePath(mv.Module)
901+
escVer, _ := module.EscapeVersion(mv.Version)
902+
return "cache/download/" + escMod + "/@v/" + escVer + "." + ext
903+
}
904+
905+
func zipRoot(mv store.ModuleVersion) string {
906+
escMod, _ := module.EscapePath(mv.Module)
907+
escVer, _ := module.EscapeVersion(mv.Version)
908+
return escMod + "@v" + escVer
909+
}
910+
707911
type memFile struct {
708912
fs.Inode
709913
contents []byte

gomodfs_test.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"flag"
88
"fmt"
99
"os"
10+
"os/exec"
1011
"path/filepath"
1112
"testing"
1213
"time"
@@ -18,7 +19,12 @@ import (
1819

1920
var debugFUSE = flag.Bool("debug-fuse", false, "verbose FUSE debugging")
2021

22+
var hitNetwork = flag.Bool("run-network-tests", false, "run network tests")
23+
2124
func TestFilesystem(t *testing.T) {
25+
if !*hitNetwork {
26+
t.Skip("Skipping network tests; set --run-network-tests to run them")
27+
}
2228

2329
type testEnv struct {
2430
*testing.T
@@ -125,9 +131,19 @@ func TestFilesystem(t *testing.T) {
125131

126132
goModCacheDir := t.TempDir()
127133
t.Logf("mount: %v", goModCacheDir)
134+
gitDir := t.TempDir()
128135

129-
store := &gitstore.Storage{GitRepo: "."}
130-
mfs := &FS{Store: store}
136+
cmd := exec.Command("git", "init", gitDir)
137+
cmd.Dir = gitDir
138+
if err := cmd.Run(); err != nil {
139+
t.Fatalf("Failed to init git repo: %v", err)
140+
}
141+
142+
store := &gitstore.Storage{GitRepo: gitDir}
143+
mfs := &FS{
144+
Store: store,
145+
Verbose: true,
146+
}
131147

132148
root := &moduleNameNode{
133149
fs: mfs,

0 commit comments

Comments
 (0)