diff --git a/bcachefs/bcachefs.go b/bcachefs/bcachefs.go new file mode 100644 index 00000000..aadb3b76 --- /dev/null +++ b/bcachefs/bcachefs.go @@ -0,0 +1,95 @@ +// Copyright 2017 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package bcachefs provides access to statistics exposed by bcachefs. +package bcachefs + +// Stats contains bcachefs runtime statistics, parsed from /sys/fs/bcachefs/. +// +// The names and meanings of each statistic were taken from bcache.txt and +// files in drivers/md/bcache in the Linux kernel source. Counters are uint64 +// (in-kernel counters are mostly unsigned long). +type Stats struct { + // The name of the bcachefs used to source these statistics. + Name string + Bcachefs BcachefsStats + Bdevs []BdevStats + Caches []CacheStats +} + +// BcachefsStats contains statistics tied to a bcache ID. +type BcachefsStats struct { // nolint:revive + AverageKeySize uint64 + BtreeCacheSize uint64 + CacheAvailablePercent uint64 + Congested uint64 + RootUsagePercent uint64 + TreeDepth uint64 + Internal InternalStats + FiveMin PeriodStats + Total PeriodStats +} + +// BdevStats contains statistics for one backing device. +type BdevStats struct { + Name string + DirtyData uint64 + FiveMin PeriodStats + Total PeriodStats + WritebackRateDebug WritebackRateDebugStats +} + +// CacheStats contains statistics for one cache device. +type CacheStats struct { + Name string + IOErrors uint64 + MetadataWritten uint64 + Written uint64 + Priority PriorityStats +} + +// PriorityStats contains statistics from the priority_stats file. +type PriorityStats struct { + UnusedPercent uint64 + MetadataPercent uint64 +} + +// InternalStats contains internal bcache statistics. +type InternalStats struct { + ActiveJournalEntries uint64 + BtreeNodes uint64 + BtreeReadAverageDurationNanoSeconds uint64 + CacheReadRaces uint64 +} + +// PeriodStats contains statistics for a time period (5 min or total). +type PeriodStats struct { + Bypassed uint64 + CacheBypassHits uint64 + CacheBypassMisses uint64 + CacheHits uint64 + CacheMissCollisions uint64 + CacheMisses uint64 + CacheReadaheads uint64 +} + +// WritebackRateDebugStats contains bcache writeback statistics. +type WritebackRateDebugStats struct { + Rate uint64 + Dirty uint64 + Target uint64 + Proportional int64 + Integral int64 + Change int64 + NextIO int64 +} diff --git a/bcachefs/get.go b/bcachefs/get.go new file mode 100644 index 00000000..4405ee3d --- /dev/null +++ b/bcachefs/get.go @@ -0,0 +1,509 @@ +// Copyright 2017 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bcachefs + +import ( + "bufio" + "fmt" + "os" + "path" + "path/filepath" + "strconv" + "strings" + + "github.com/prometheus/procfs/internal/fs" +) + +// FS represents the pseudo-filesystem proc, which provides an interface to +// kernel data structures. +type FS struct { + sys *fs.FS +} + +// NewDefaultFS returns a new Bcache using the default sys fs mount point. It will error +// if the mount point can't be read. +func NewDefaultFS() (FS, error) { + return NewFS(fs.DefaultSysMountPoint) +} + +// NewFS returns a new Bcache using the given sys fs mount point. It will error +// if the mount point can't be read. +func NewFS(mountPoint string) (FS, error) { + if strings.TrimSpace(mountPoint) == "" { + mountPoint = fs.DefaultSysMountPoint + } + fs, err := fs.NewFS(mountPoint) + if err != nil { + return FS{}, err + } + return FS{&fs}, nil +} + +// Stats is a wrapper around stats(). +// It returns full available statistics. +func (fs FS) Stats() ([]*Stats, error) { + return fs.stats(true) +} + +// StatsWithoutPriority is a wrapper around stats(). +// It ignores priority_stats file, because it is expensive to read. +func (fs FS) StatsWithoutPriority() ([]*Stats, error) { + return fs.stats(false) +} + +// stats retrieves bcache runtime statistics for each bcache and +// priorityStats flag controls if we need to read priority_stats. +func (fs FS) stats(priorityStats bool) ([]*Stats, error) { + matches, err := filepath.Glob(fs.sys.Path("fs/bcache/*-*")) + if err != nil { + return nil, err + } + + stats := make([]*Stats, 0, len(matches)) + for _, uuidPath := range matches { + // "*-*" in glob above indicates the name of the bcache. + name := filepath.Base(uuidPath) + + // stats + s, err := GetStats(uuidPath, priorityStats) + if err != nil { + return nil, err + } + + s.Name = name + stats = append(stats, s) + } + + return stats, nil +} + +// ParsePseudoFloat parses the peculiar format produced by bcache's bch_hprint. +func parsePseudoFloat(str string) (float64, error) { + ss := strings.Split(str, ".") + + intPart, err := strconv.ParseFloat(ss[0], 64) + if err != nil { + return 0, err + } + + if len(ss) == 1 { + // Pure integers are fine. + return intPart, nil + } + fracPart, err := strconv.ParseFloat(ss[1], 64) + if err != nil { + return 0, err + } + // fracPart is a number between 0 and 1023 divided by 100; it is off + // by a small amount. Unexpected bumps in time lines may occur because + // for bch_hprint .1 != .10 and .10 > .9 (at least up to Linux + // v4.12-rc3). + + // Restore the proper order: + fracPart = fracPart / 10.24 + return intPart + fracPart, nil +} + +// Dehumanize converts a human-readable byte slice into a uint64. +func dehumanize(hbytes []byte) (uint64, error) { + ll := len(hbytes) + if ll == 0 { + return 0, fmt.Errorf("zero-length reply") + } + lastByte := hbytes[ll-1] + mul := float64(1) + var ( + mant float64 + err error + ) + // If lastByte is beyond the range of ASCII digits, it must be a + // multiplier. + if lastByte > 57 { + // Remove multiplier from slice. + hbytes = hbytes[:len(hbytes)-1] + + const ( + _ = 1 << (10 * iota) + KiB + MiB + GiB + TiB + PiB + EiB + ZiB + YiB + ) + + multipliers := map[rune]float64{ + // Source for conversion rules: + // linux-kernel/drivers/md/bcache/util.c:bch_hprint() + 'k': KiB, + 'M': MiB, + 'G': GiB, + 'T': TiB, + 'P': PiB, + 'E': EiB, + 'Z': ZiB, + 'Y': YiB, + } + mul = multipliers[rune(lastByte)] + mant, err = parsePseudoFloat(string(hbytes)) + if err != nil { + return 0, err + } + } else { + // Not humanized by bch_hprint + mant, err = strconv.ParseFloat(string(hbytes), 64) + if err != nil { + return 0, err + } + } + res := uint64(mant * mul) + return res, nil +} + +func dehumanizeSigned(str string) (int64, error) { + value, err := dehumanize([]byte(strings.TrimPrefix(str, "-"))) + if err != nil { + return 0, err + } + if strings.HasPrefix(str, "-") { + return int64(-value), nil + } + return int64(value), nil +} + +type parser struct { + uuidPath string + subDir string + currentDir string + err error +} + +func (p *parser) setSubDir(pathElements ...string) { + p.subDir = path.Join(pathElements...) + p.currentDir = path.Join(p.uuidPath, p.subDir) +} + +// readValues reads a number of numerical values into an uint64 slice. +// Non-existing files are ignored. +func (p *parser) readValue(fileName string) uint64 { + if p.err != nil { + return 0 + } + path := path.Join(p.currentDir, fileName) + byt, err := os.ReadFile(path) + if err != nil { + if !os.IsNotExist(err) { + p.err = fmt.Errorf("failed to read: %s", path) + } + return 0 + } + // Remove trailing newline. + byt = byt[:len(byt)-1] + res, err := dehumanize(byt) + p.err = err + return res +} + +// ParsePriorityStats parses lines from the priority_stats file. +func parsePriorityStats(line string, ps *PriorityStats) error { + var ( + value uint64 + err error + ) + switch { + case strings.HasPrefix(line, "Unused:"): + fields := strings.Fields(line) + rawValue := fields[len(fields)-1] + valueStr := strings.TrimSuffix(rawValue, "%") + value, err = strconv.ParseUint(valueStr, 10, 64) + if err != nil { + return err + } + ps.UnusedPercent = value + case strings.HasPrefix(line, "Metadata:"): + fields := strings.Fields(line) + rawValue := fields[len(fields)-1] + valueStr := strings.TrimSuffix(rawValue, "%") + value, err = strconv.ParseUint(valueStr, 10, 64) + if err != nil { + return err + } + ps.MetadataPercent = value + } + return nil +} + +// ParseWritebackRateDebug parses lines from the writeback_rate_debug file. +func parseWritebackRateDebug(line string, wrd *WritebackRateDebugStats) error { + switch { + case strings.HasPrefix(line, "rate:"): + fields := strings.Fields(line) + rawValue := fields[len(fields)-1] + valueStr := strings.TrimSuffix(rawValue, "/sec") + value, err := dehumanize([]byte(valueStr)) + if err != nil { + return err + } + wrd.Rate = value + case strings.HasPrefix(line, "dirty:"): + fields := strings.Fields(line) + valueStr := fields[len(fields)-1] + value, err := dehumanize([]byte(valueStr)) + if err != nil { + return err + } + wrd.Dirty = value + case strings.HasPrefix(line, "target:"): + fields := strings.Fields(line) + valueStr := fields[len(fields)-1] + value, err := dehumanize([]byte(valueStr)) + if err != nil { + return err + } + wrd.Target = value + case strings.HasPrefix(line, "proportional:"): + fields := strings.Fields(line) + valueStr := fields[len(fields)-1] + value, err := dehumanizeSigned(valueStr) + if err != nil { + return err + } + wrd.Proportional = value + case strings.HasPrefix(line, "integral:"): + fields := strings.Fields(line) + valueStr := fields[len(fields)-1] + value, err := dehumanizeSigned(valueStr) + if err != nil { + return err + } + wrd.Integral = value + case strings.HasPrefix(line, "change:"): + fields := strings.Fields(line) + rawValue := fields[len(fields)-1] + valueStr := strings.TrimSuffix(rawValue, "/sec") + value, err := dehumanizeSigned(valueStr) + if err != nil { + return err + } + wrd.Change = value + case strings.HasPrefix(line, "next io:"): + fields := strings.Fields(line) + rawValue := fields[len(fields)-1] + valueStr := strings.TrimSuffix(rawValue, "ms") + value, err := strconv.ParseInt(valueStr, 10, 64) + if err != nil { + return err + } + wrd.NextIO = value + } + return nil +} + +func (p *parser) getPriorityStats() PriorityStats { + var res PriorityStats + + if p.err != nil { + return res + } + + path := path.Join(p.currentDir, "priority_stats") + + file, err := os.Open(path) + if err != nil { + p.err = fmt.Errorf("failed to read: %s", path) + return res + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + err = parsePriorityStats(scanner.Text(), &res) + if err != nil { + p.err = fmt.Errorf("failed to parse path %q: %w", path, err) + return res + } + } + if err := scanner.Err(); err != nil { + p.err = fmt.Errorf("failed to parse path %q: %w", path, err) + return res + } + return res +} + +func (p *parser) getWritebackRateDebug() WritebackRateDebugStats { + var res WritebackRateDebugStats + + if p.err != nil { + return res + } + path := path.Join(p.currentDir, "writeback_rate_debug") + file, err := os.Open(path) + if err != nil { + p.err = fmt.Errorf("failed to read: %s", path) + return res + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + err = parseWritebackRateDebug(scanner.Text(), &res) + if err != nil { + p.err = fmt.Errorf("failed to parse path %q: %w", path, err) + return res + } + } + if err := scanner.Err(); err != nil { + p.err = fmt.Errorf("failed to parse path %q: %w", path, err) + return res + } + return res +} + +// GetStats collects from sysfs files data tied to one bcache ID. +func GetStats(uuidPath string, priorityStats bool) (*Stats, error) { + var bs Stats + + par := parser{uuidPath: uuidPath} + + // bcache stats + + // dir + par.setSubDir("") + bs.Bcache.AverageKeySize = par.readValue("average_key_size") + bs.Bcache.BtreeCacheSize = par.readValue("btree_cache_size") + bs.Bcache.CacheAvailablePercent = par.readValue("cache_available_percent") + bs.Bcache.Congested = par.readValue("congested") + bs.Bcache.RootUsagePercent = par.readValue("root_usage_percent") + bs.Bcache.TreeDepth = par.readValue("tree_depth") + + // bcache stats (internal) + + // dir /internal + par.setSubDir("internal") + bs.Bcache.Internal.ActiveJournalEntries = par.readValue("active_journal_entries") + bs.Bcache.Internal.BtreeNodes = par.readValue("btree_nodes") + bs.Bcache.Internal.BtreeReadAverageDurationNanoSeconds = par.readValue("btree_read_average_duration_us") + bs.Bcache.Internal.CacheReadRaces = par.readValue("cache_read_races") + + // bcache stats (period) + + // dir /stats_five_minute + par.setSubDir("stats_five_minute") + bs.Bcache.FiveMin.Bypassed = par.readValue("bypassed") + bs.Bcache.FiveMin.CacheHits = par.readValue("cache_hits") + + bs.Bcache.FiveMin.Bypassed = par.readValue("bypassed") + bs.Bcache.FiveMin.CacheBypassHits = par.readValue("cache_bypass_hits") + bs.Bcache.FiveMin.CacheBypassMisses = par.readValue("cache_bypass_misses") + bs.Bcache.FiveMin.CacheHits = par.readValue("cache_hits") + bs.Bcache.FiveMin.CacheMissCollisions = par.readValue("cache_miss_collisions") + bs.Bcache.FiveMin.CacheMisses = par.readValue("cache_misses") + bs.Bcache.FiveMin.CacheReadaheads = par.readValue("cache_readaheads") + + // dir /stats_total + par.setSubDir("stats_total") + bs.Bcache.Total.Bypassed = par.readValue("bypassed") + bs.Bcache.Total.CacheHits = par.readValue("cache_hits") + + bs.Bcache.Total.Bypassed = par.readValue("bypassed") + bs.Bcache.Total.CacheBypassHits = par.readValue("cache_bypass_hits") + bs.Bcache.Total.CacheBypassMisses = par.readValue("cache_bypass_misses") + bs.Bcache.Total.CacheHits = par.readValue("cache_hits") + bs.Bcache.Total.CacheMissCollisions = par.readValue("cache_miss_collisions") + bs.Bcache.Total.CacheMisses = par.readValue("cache_misses") + bs.Bcache.Total.CacheReadaheads = par.readValue("cache_readaheads") + + if par.err != nil { + return nil, par.err + } + + // bdev stats + + reg := path.Join(uuidPath, "bdev[0-9]*") + bdevDirs, err := filepath.Glob(reg) + if err != nil { + return nil, err + } + + bs.Bdevs = make([]BdevStats, len(bdevDirs)) + + for ii, bdevDir := range bdevDirs { + var bds = &bs.Bdevs[ii] + + bds.Name = filepath.Base(bdevDir) + + par.setSubDir(bds.Name) + bds.DirtyData = par.readValue("dirty_data") + + wrd := par.getWritebackRateDebug() + bds.WritebackRateDebug = wrd + + // dir //stats_five_minute + par.setSubDir(bds.Name, "stats_five_minute") + bds.FiveMin.Bypassed = par.readValue("bypassed") + bds.FiveMin.CacheBypassHits = par.readValue("cache_bypass_hits") + bds.FiveMin.CacheBypassMisses = par.readValue("cache_bypass_misses") + bds.FiveMin.CacheHits = par.readValue("cache_hits") + bds.FiveMin.CacheMissCollisions = par.readValue("cache_miss_collisions") + bds.FiveMin.CacheMisses = par.readValue("cache_misses") + bds.FiveMin.CacheReadaheads = par.readValue("cache_readaheads") + + // dir //stats_total + par.setSubDir(bds.Name, "stats_total") + bds.Total.Bypassed = par.readValue("bypassed") + bds.Total.CacheBypassHits = par.readValue("cache_bypass_hits") + bds.Total.CacheBypassMisses = par.readValue("cache_bypass_misses") + bds.Total.CacheHits = par.readValue("cache_hits") + bds.Total.CacheMissCollisions = par.readValue("cache_miss_collisions") + bds.Total.CacheMisses = par.readValue("cache_misses") + bds.Total.CacheReadaheads = par.readValue("cache_readaheads") + } + + if par.err != nil { + return nil, par.err + } + + // cache stats + + reg = path.Join(uuidPath, "cache[0-9]*") + cacheDirs, err := filepath.Glob(reg) + if err != nil { + return nil, err + } + bs.Caches = make([]CacheStats, len(cacheDirs)) + + for ii, cacheDir := range cacheDirs { + var cs = &bs.Caches[ii] + cs.Name = filepath.Base(cacheDir) + + // dir is / + par.setSubDir(cs.Name) + cs.IOErrors = par.readValue("io_errors") + cs.MetadataWritten = par.readValue("metadata_written") + cs.Written = par.readValue("written") + + if priorityStats { + ps := par.getPriorityStats() + cs.Priority = ps + } + } + + if par.err != nil { + return nil, par.err + } + + return &bs, nil +} diff --git a/bcachefs/get_test.go b/bcachefs/get_test.go new file mode 100644 index 00000000..0954f9dc --- /dev/null +++ b/bcachefs/get_test.go @@ -0,0 +1,218 @@ +// Copyright 2017 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bcachefs + +import ( + "math" + "testing" +) + +func TestFSBcachefsStats(t *testing.T) { + bcache, err := NewFS("testdata/fixtures/sys") + if err != nil { + t.Fatalf("failed to access bcache fs: %v", err) + } + stats, err := bcache.Stats() + if err != nil { + t.Fatalf("failed to parse bcache stats: %v", err) + } + + tests := []struct { + name string + bdevs int + caches int + }{ + { + name: "deaddd54-c735-46d5-868e-f331c5fd7c74", + bdevs: 1, + caches: 1, + }, + } + + const expect = 1 + + if l := len(stats); l != expect { + t.Fatalf("unexpected number of bcache stats: %d", l) + } + if l := len(tests); l != expect { + t.Fatalf("unexpected number of tests: %d", l) + } + + for i, tt := range tests { + if want, got := tt.name, stats[i].Name; want != got { + t.Errorf("unexpected stats name:\nwant: %q\nhave: %q", want, got) + } + + if want, got := tt.bdevs, len(stats[i].Bdevs); want != got { + t.Errorf("unexpected value allocated:\nwant: %d\nhave: %d", want, got) + } + + if want, got := tt.caches, len(stats[i].Caches); want != got { + t.Errorf("unexpected value allocated:\nwant: %d\nhave: %d", want, got) + } + } +} + +func TestDehumanizeTests(t *testing.T) { + dehumanizeTests := []struct { + in []byte + out uint64 + invalid bool + }{ + { + in: []byte("542k"), + out: 555008, + }, + { + in: []byte("322M"), + out: 337641472, + }, + { + in: []byte("1.1k"), + out: 1124, + }, + { + in: []byte("1.9k"), + out: 1924, + }, + { + in: []byte("1.10k"), + out: 2024, + }, + { + in: []byte(""), + out: 0, + invalid: true, + }, + } + for _, tst := range dehumanizeTests { + got, err := dehumanize(tst.in) + if tst.invalid && err == nil { + t.Error("expected an error, but none occurred") + } + if !tst.invalid && err != nil { + t.Errorf("unexpected error: %v", err) + } + if got != tst.out { + t.Errorf("dehumanize: '%s', want %d, got %d", tst.in, tst.out, got) + } + } +} + +func TestParsePseudoFloatTests(t *testing.T) { + parsePseudoFloatTests := []struct { + in string + out float64 + }{ + { + in: "1.1", + out: float64(1.097656), + }, + { + in: "1.9", + out: float64(1.878906), + }, + { + in: "1.10", + out: float64(1.976562), + }, + } + for _, tst := range parsePseudoFloatTests { + got, err := parsePseudoFloat(tst.in) + if err != nil || math.Abs(got-tst.out) > 0.0001 { + t.Errorf("parsePseudoFloat: %s, want %f, got %f", tst.in, tst.out, got) + } + } +} + +func TestPriorityStats(t *testing.T) { + var want = PriorityStats{ + UnusedPercent: 99, + MetadataPercent: 5, + } + var ( + in string + gotErr error + got PriorityStats + ) + in = "Metadata: 5%" + gotErr = parsePriorityStats(in, &got) + if gotErr != nil || got.MetadataPercent != want.MetadataPercent { + t.Errorf("parsePriorityStats: '%s', want %d, got %d", in, want.MetadataPercent, got.MetadataPercent) + } + + in = "Unused: 99%" + gotErr = parsePriorityStats(in, &got) + if gotErr != nil || got.UnusedPercent != want.UnusedPercent { + t.Errorf("parsePriorityStats: '%s', want %d, got %d", in, want.UnusedPercent, got.UnusedPercent) + } +} + +func TestWritebackRateDebug(t *testing.T) { + var want = WritebackRateDebugStats{ + Rate: 1765376, + Dirty: 21789409280, + Target: 21894266880, + Proportional: -1124, + Integral: -257624, + Change: 2648, + NextIO: -150773, + } + var ( + in string + gotErr error + got WritebackRateDebugStats + ) + in = "rate: 1.7M/sec" + gotErr = parseWritebackRateDebug(in, &got) + if gotErr != nil || got.Rate != want.Rate { + t.Errorf("parsePriorityStats: '%s', want %d, got %d", in, want.Rate, got.Rate) + } + + in = "dirty: 20.3G" + gotErr = parseWritebackRateDebug(in, &got) + if gotErr != nil || got.Dirty != want.Dirty { + t.Errorf("parsePriorityStats: '%s', want %d, got %d", in, want.Dirty, got.Dirty) + } + + in = "target: 20.4G" + gotErr = parseWritebackRateDebug(in, &got) + if gotErr != nil || got.Target != want.Target { + t.Errorf("parsePriorityStats: '%s', want %d, got %d", in, want.Target, got.Target) + } + + in = "proportional: -1.1k" + gotErr = parseWritebackRateDebug(in, &got) + if gotErr != nil || got.Proportional != want.Proportional { + t.Errorf("parsePriorityStats: '%s', want %d, got %d", in, want.Proportional, got.Proportional) + } + + in = "integral: -251.6k" + gotErr = parseWritebackRateDebug(in, &got) + if gotErr != nil || got.Integral != want.Integral { + t.Errorf("parsePriorityStats: '%s', want %d, got %d", in, want.Integral, got.Integral) + } + + in = "change: 2.6k/sec" + gotErr = parseWritebackRateDebug(in, &got) + if gotErr != nil || got.Change != want.Change { + t.Errorf("parsePriorityStats: '%s', want %d, got %d", in, want.Change, got.Change) + } + + in = "next io: -150773ms" + gotErr = parseWritebackRateDebug(in, &got) + if gotErr != nil || got.NextIO != want.NextIO { + t.Errorf("parsePriorityStats: '%s', want %d, got %d", in, want.NextIO, got.NextIO) + } +} diff --git a/bcachefs/testdata/fixtures b/bcachefs/testdata/fixtures new file mode 120000 index 00000000..5fdc97be --- /dev/null +++ b/bcachefs/testdata/fixtures @@ -0,0 +1 @@ +../../testdata/fixtures \ No newline at end of file