2020-03-23 11:40:02 +00:00
|
|
|
package stores
|
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
|
|
|
"encoding/json"
|
|
|
|
"io/ioutil"
|
|
|
|
"math/bits"
|
2020-05-08 16:08:48 +00:00
|
|
|
"math/rand"
|
2020-03-23 11:40:02 +00:00
|
|
|
"os"
|
|
|
|
"path/filepath"
|
|
|
|
"sync"
|
2020-05-08 16:08:48 +00:00
|
|
|
"time"
|
2020-03-23 11:40:02 +00:00
|
|
|
|
|
|
|
"golang.org/x/xerrors"
|
|
|
|
|
2020-03-27 20:08:06 +00:00
|
|
|
"github.com/filecoin-project/specs-actors/actors/abi"
|
2020-03-23 11:40:02 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
type StoragePath struct {
|
|
|
|
ID ID
|
|
|
|
Weight uint64
|
|
|
|
|
|
|
|
LocalPath string
|
|
|
|
|
|
|
|
CanSeal bool
|
|
|
|
CanStore bool
|
|
|
|
}
|
|
|
|
|
|
|
|
// [path]/sectorstore.json
|
|
|
|
type LocalStorageMeta struct {
|
|
|
|
ID ID
|
|
|
|
Weight uint64 // 0 = readonly
|
|
|
|
|
|
|
|
CanSeal bool
|
|
|
|
CanStore bool
|
|
|
|
}
|
|
|
|
|
2020-03-27 20:08:06 +00:00
|
|
|
// .lotusstorage/storage.json
|
|
|
|
type StorageConfig struct {
|
|
|
|
StoragePaths []LocalPath
|
|
|
|
}
|
|
|
|
|
|
|
|
type LocalPath struct {
|
|
|
|
Path string
|
|
|
|
}
|
|
|
|
|
2020-03-23 11:40:02 +00:00
|
|
|
type LocalStorage interface {
|
2020-03-27 20:08:06 +00:00
|
|
|
GetStorage() (StorageConfig, error)
|
|
|
|
SetStorage(func(*StorageConfig)) error
|
2020-05-20 16:36:46 +00:00
|
|
|
|
|
|
|
Stat(path string) (FsStat, error)
|
2020-03-23 11:40:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
const MetaFile = "sectorstore.json"
|
|
|
|
|
2020-03-27 17:21:32 +00:00
|
|
|
var PathTypes = []SectorFileType{FTUnsealed, FTSealed, FTCache}
|
2020-03-23 11:40:02 +00:00
|
|
|
|
|
|
|
type Local struct {
|
|
|
|
localStorage LocalStorage
|
|
|
|
index SectorIndex
|
|
|
|
urls []string
|
|
|
|
|
|
|
|
paths map[ID]*path
|
|
|
|
|
|
|
|
localLk sync.RWMutex
|
|
|
|
}
|
|
|
|
|
|
|
|
type path struct {
|
|
|
|
local string // absolute local path
|
|
|
|
}
|
|
|
|
|
|
|
|
func NewLocal(ctx context.Context, ls LocalStorage, index SectorIndex, urls []string) (*Local, error) {
|
|
|
|
l := &Local{
|
|
|
|
localStorage: ls,
|
|
|
|
index: index,
|
|
|
|
urls: urls,
|
|
|
|
|
|
|
|
paths: map[ID]*path{},
|
|
|
|
}
|
|
|
|
return l, l.open(ctx)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (st *Local) OpenPath(ctx context.Context, p string) error {
|
|
|
|
st.localLk.Lock()
|
|
|
|
defer st.localLk.Unlock()
|
|
|
|
|
|
|
|
mb, err := ioutil.ReadFile(filepath.Join(p, MetaFile))
|
|
|
|
if err != nil {
|
|
|
|
return xerrors.Errorf("reading storage metadata for %s: %w", p, err)
|
|
|
|
}
|
|
|
|
|
|
|
|
var meta LocalStorageMeta
|
|
|
|
if err := json.Unmarshal(mb, &meta); err != nil {
|
|
|
|
return xerrors.Errorf("unmarshalling storage metadata for %s: %w", p, err)
|
|
|
|
}
|
|
|
|
|
|
|
|
// TODO: Check existing / dedupe
|
|
|
|
|
|
|
|
out := &path{
|
|
|
|
local: p,
|
|
|
|
}
|
|
|
|
|
2020-05-20 16:36:46 +00:00
|
|
|
fst, err := st.localStorage.Stat(p)
|
2020-03-23 11:40:02 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
err = st.index.StorageAttach(ctx, StorageInfo{
|
|
|
|
ID: meta.ID,
|
|
|
|
URLs: st.urls,
|
|
|
|
Weight: meta.Weight,
|
|
|
|
CanSeal: meta.CanSeal,
|
|
|
|
CanStore: meta.CanStore,
|
|
|
|
}, fst)
|
|
|
|
if err != nil {
|
|
|
|
return xerrors.Errorf("declaring storage in index: %w", err)
|
|
|
|
}
|
|
|
|
|
2020-03-27 17:21:32 +00:00
|
|
|
for _, t := range PathTypes {
|
2020-03-23 11:40:02 +00:00
|
|
|
ents, err := ioutil.ReadDir(filepath.Join(p, t.String()))
|
|
|
|
if err != nil {
|
|
|
|
if os.IsNotExist(err) {
|
|
|
|
if err := os.MkdirAll(filepath.Join(p, t.String()), 0755); err != nil {
|
|
|
|
return xerrors.Errorf("openPath mkdir '%s': %w", filepath.Join(p, t.String()), err)
|
|
|
|
}
|
|
|
|
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
return xerrors.Errorf("listing %s: %w", filepath.Join(p, t.String()), err)
|
|
|
|
}
|
|
|
|
|
|
|
|
for _, ent := range ents {
|
2020-03-26 02:50:56 +00:00
|
|
|
sid, err := ParseSectorID(ent.Name())
|
2020-03-23 11:40:02 +00:00
|
|
|
if err != nil {
|
|
|
|
return xerrors.Errorf("parse sector id %s: %w", ent.Name(), err)
|
|
|
|
}
|
|
|
|
|
2020-05-20 16:36:46 +00:00
|
|
|
if err := st.index.StorageDeclareSector(ctx, meta.ID, sid, t, meta.CanStore); err != nil {
|
2020-03-23 11:40:02 +00:00
|
|
|
return xerrors.Errorf("declare sector %d(t:%d) -> %s: %w", sid, t, meta.ID, err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
st.paths[meta.ID] = out
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (st *Local) open(ctx context.Context) error {
|
|
|
|
cfg, err := st.localStorage.GetStorage()
|
|
|
|
if err != nil {
|
|
|
|
return xerrors.Errorf("getting local storage config: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
for _, path := range cfg.StoragePaths {
|
|
|
|
err := st.OpenPath(ctx, path.Path)
|
|
|
|
if err != nil {
|
|
|
|
return xerrors.Errorf("opening path %s: %w", path.Path, err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-05-08 16:08:48 +00:00
|
|
|
go st.reportHealth(ctx)
|
|
|
|
|
2020-03-23 11:40:02 +00:00
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2020-05-08 16:08:48 +00:00
|
|
|
func (st *Local) reportHealth(ctx context.Context) {
|
|
|
|
// randomize interval by ~10%
|
2020-05-08 16:54:06 +00:00
|
|
|
interval := (HeartbeatInterval*100_000 + time.Duration(rand.Int63n(10_000))) / 100_000
|
2020-05-08 16:08:48 +00:00
|
|
|
|
|
|
|
for {
|
|
|
|
select {
|
|
|
|
case <-time.After(interval):
|
|
|
|
case <-ctx.Done():
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
st.localLk.RLock()
|
|
|
|
|
|
|
|
toReport := map[ID]HealthReport{}
|
|
|
|
for id, p := range st.paths {
|
2020-05-20 16:36:46 +00:00
|
|
|
stat, err := st.localStorage.Stat(p.local)
|
2020-05-08 16:08:48 +00:00
|
|
|
|
|
|
|
toReport[id] = HealthReport{
|
|
|
|
Stat: stat,
|
|
|
|
Err: err,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
st.localLk.RUnlock()
|
|
|
|
|
|
|
|
for id, report := range toReport {
|
|
|
|
if err := st.index.StorageReportHealth(ctx, id, report); err != nil {
|
|
|
|
log.Warnf("error reporting storage health for %s: %+v", id, report)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-05-20 16:36:46 +00:00
|
|
|
func (st *Local) AcquireSector(ctx context.Context, sid abi.SectorID, spt abi.RegisteredProof, existing SectorFileType, allocate SectorFileType, pathType PathType, op AcquireMode) (SectorPaths, SectorPaths, func(), error) {
|
2020-03-23 11:40:02 +00:00
|
|
|
if existing|allocate != existing^allocate {
|
2020-03-26 02:50:56 +00:00
|
|
|
return SectorPaths{}, SectorPaths{}, nil, xerrors.New("can't both find and allocate a sector")
|
2020-03-23 11:40:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
st.localLk.RLock()
|
|
|
|
|
2020-03-26 02:50:56 +00:00
|
|
|
var out SectorPaths
|
|
|
|
var storageIDs SectorPaths
|
2020-03-23 11:40:02 +00:00
|
|
|
|
2020-03-27 17:21:32 +00:00
|
|
|
for _, fileType := range PathTypes {
|
2020-03-23 11:40:02 +00:00
|
|
|
if fileType&existing == 0 {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
si, err := st.index.StorageFindSector(ctx, sid, fileType, false)
|
|
|
|
if err != nil {
|
|
|
|
log.Warnf("finding existing sector %d(t:%d) failed: %+v", sid, fileType, err)
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
for _, info := range si {
|
|
|
|
p, ok := st.paths[info.ID]
|
|
|
|
if !ok {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
if p.local == "" { // TODO: can that even be the case?
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
2020-03-26 02:50:56 +00:00
|
|
|
spath := filepath.Join(p.local, fileType.String(), SectorName(sid))
|
|
|
|
SetPathByType(&out, fileType, spath)
|
|
|
|
SetPathByType(&storageIDs, fileType, string(info.ID))
|
2020-03-23 11:40:02 +00:00
|
|
|
|
|
|
|
existing ^= fileType
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-03-27 17:21:32 +00:00
|
|
|
for _, fileType := range PathTypes {
|
2020-03-23 11:40:02 +00:00
|
|
|
if fileType&allocate == 0 {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
2020-05-20 16:36:46 +00:00
|
|
|
sis, err := st.index.StorageBestAlloc(ctx, fileType, spt, pathType)
|
2020-03-23 11:40:02 +00:00
|
|
|
if err != nil {
|
|
|
|
st.localLk.RUnlock()
|
2020-03-26 02:50:56 +00:00
|
|
|
return SectorPaths{}, SectorPaths{}, nil, xerrors.Errorf("finding best storage for allocating : %w", err)
|
2020-03-23 11:40:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
var best string
|
|
|
|
var bestID ID
|
|
|
|
|
|
|
|
for _, si := range sis {
|
|
|
|
p, ok := st.paths[si.ID]
|
|
|
|
if !ok {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
if p.local == "" { // TODO: can that even be the case?
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
2020-05-20 16:36:46 +00:00
|
|
|
if (pathType == PathSealing) && !si.CanSeal {
|
2020-03-23 11:40:02 +00:00
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
2020-05-20 16:36:46 +00:00
|
|
|
if (pathType == PathStorage) && !si.CanStore {
|
2020-03-23 11:40:02 +00:00
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
// TODO: Check free space
|
|
|
|
|
2020-03-26 02:50:56 +00:00
|
|
|
best = filepath.Join(p.local, fileType.String(), SectorName(sid))
|
2020-03-23 11:40:02 +00:00
|
|
|
bestID = si.ID
|
|
|
|
}
|
|
|
|
|
|
|
|
if best == "" {
|
|
|
|
st.localLk.RUnlock()
|
2020-03-26 02:50:56 +00:00
|
|
|
return SectorPaths{}, SectorPaths{}, nil, xerrors.Errorf("couldn't find a suitable path for a sector")
|
2020-03-23 11:40:02 +00:00
|
|
|
}
|
|
|
|
|
2020-03-26 02:50:56 +00:00
|
|
|
SetPathByType(&out, fileType, best)
|
|
|
|
SetPathByType(&storageIDs, fileType, string(bestID))
|
2020-03-23 11:40:02 +00:00
|
|
|
allocate ^= fileType
|
|
|
|
}
|
|
|
|
|
|
|
|
return out, storageIDs, st.localLk.RUnlock, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (st *Local) Local(ctx context.Context) ([]StoragePath, error) {
|
|
|
|
st.localLk.RLock()
|
|
|
|
defer st.localLk.RUnlock()
|
|
|
|
|
|
|
|
var out []StoragePath
|
|
|
|
for id, p := range st.paths {
|
|
|
|
if p.local == "" {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
si, err := st.index.StorageInfo(ctx, id)
|
|
|
|
if err != nil {
|
|
|
|
return nil, xerrors.Errorf("get storage info for %s: %w", id, err)
|
|
|
|
}
|
|
|
|
|
|
|
|
out = append(out, StoragePath{
|
|
|
|
ID: id,
|
|
|
|
Weight: si.Weight,
|
|
|
|
LocalPath: p.local,
|
|
|
|
CanSeal: si.CanSeal,
|
|
|
|
CanStore: si.CanStore,
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
return out, nil
|
|
|
|
}
|
|
|
|
|
2020-05-13 18:45:14 +00:00
|
|
|
func (st *Local) Remove(ctx context.Context, sid abi.SectorID, typ SectorFileType, force bool) error {
|
2020-03-23 11:40:02 +00:00
|
|
|
if bits.OnesCount(uint(typ)) != 1 {
|
|
|
|
return xerrors.New("delete expects one file type")
|
|
|
|
}
|
|
|
|
|
|
|
|
si, err := st.index.StorageFindSector(ctx, sid, typ, false)
|
|
|
|
if err != nil {
|
|
|
|
return xerrors.Errorf("finding existing sector %d(t:%d) failed: %w", sid, typ, err)
|
|
|
|
}
|
|
|
|
|
2020-05-13 18:45:14 +00:00
|
|
|
if len(si) == 0 && !force {
|
2020-03-23 15:40:36 +00:00
|
|
|
return xerrors.Errorf("can't delete sector %v(%d), not found", sid, typ)
|
|
|
|
}
|
|
|
|
|
2020-03-23 11:40:02 +00:00
|
|
|
for _, info := range si {
|
2020-05-20 16:36:46 +00:00
|
|
|
if err := st.removeSector(ctx, sid, typ, info.ID); err != nil {
|
|
|
|
return err
|
2020-03-23 11:40:02 +00:00
|
|
|
}
|
2020-05-20 16:36:46 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (st *Local) RemoveCopies(ctx context.Context, sid abi.SectorID, typ SectorFileType) error {
|
|
|
|
if bits.OnesCount(uint(typ)) != 1 {
|
|
|
|
return xerrors.New("delete expects one file type")
|
|
|
|
}
|
2020-03-23 11:40:02 +00:00
|
|
|
|
2020-05-20 16:36:46 +00:00
|
|
|
si, err := st.index.StorageFindSector(ctx, sid, typ, false)
|
|
|
|
if err != nil {
|
|
|
|
return xerrors.Errorf("finding existing sector %d(t:%d) failed: %w", sid, typ, err)
|
|
|
|
}
|
|
|
|
|
|
|
|
var hasPrimary bool
|
|
|
|
for _, info := range si {
|
|
|
|
if info.Primary {
|
|
|
|
hasPrimary = true
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if !hasPrimary {
|
|
|
|
log.Warnf("RemoveCopies: no primary copies of sector %v (%s), not removing anything", sid, typ)
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
for _, info := range si {
|
|
|
|
if info.Primary {
|
2020-03-23 11:40:02 +00:00
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
2020-05-20 16:36:46 +00:00
|
|
|
if err := st.removeSector(ctx, sid, typ, info.ID); err != nil {
|
|
|
|
return err
|
2020-03-23 15:40:36 +00:00
|
|
|
}
|
2020-05-20 16:36:46 +00:00
|
|
|
}
|
2020-03-23 15:40:36 +00:00
|
|
|
|
2020-05-20 16:36:46 +00:00
|
|
|
return nil
|
|
|
|
}
|
2020-03-23 11:40:02 +00:00
|
|
|
|
2020-05-20 16:36:46 +00:00
|
|
|
func (st *Local) removeSector(ctx context.Context, sid abi.SectorID, typ SectorFileType, storage ID) error {
|
|
|
|
p, ok := st.paths[storage]
|
|
|
|
if !ok {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
if p.local == "" { // TODO: can that even be the case?
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
if err := st.index.StorageDropSector(ctx, storage, sid, typ); err != nil {
|
|
|
|
return xerrors.Errorf("dropping sector from index: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
spath := filepath.Join(p.local, typ.String(), SectorName(sid))
|
|
|
|
log.Infof("remove %s", spath)
|
|
|
|
|
|
|
|
if err := os.RemoveAll(spath); err != nil {
|
|
|
|
log.Errorf("removing sector (%v) from %s: %+v", sid, spath, err)
|
2020-03-23 11:40:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2020-05-08 16:54:06 +00:00
|
|
|
func (st *Local) MoveStorage(ctx context.Context, s abi.SectorID, spt abi.RegisteredProof, types SectorFileType) error {
|
2020-05-20 16:36:46 +00:00
|
|
|
dest, destIds, sdone, err := st.AcquireSector(ctx, s, spt, FTNone, types, false, AcquireMove)
|
2020-03-25 18:21:53 +00:00
|
|
|
if err != nil {
|
|
|
|
return xerrors.Errorf("acquire dest storage: %w", err)
|
|
|
|
}
|
|
|
|
defer sdone()
|
|
|
|
|
2020-05-20 16:36:46 +00:00
|
|
|
src, srcIds, ddone, err := st.AcquireSector(ctx, s, spt, types, FTNone, false, AcquireMove)
|
2020-03-25 18:21:53 +00:00
|
|
|
if err != nil {
|
|
|
|
return xerrors.Errorf("acquire src storage: %w", err)
|
|
|
|
}
|
|
|
|
defer ddone()
|
|
|
|
|
2020-03-27 17:21:32 +00:00
|
|
|
for _, fileType := range PathTypes {
|
2020-03-25 18:21:53 +00:00
|
|
|
if fileType&types == 0 {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
2020-03-26 02:50:56 +00:00
|
|
|
sst, err := st.index.StorageInfo(ctx, ID(PathByType(srcIds, fileType)))
|
2020-03-25 18:21:53 +00:00
|
|
|
if err != nil {
|
|
|
|
return xerrors.Errorf("failed to get source storage info: %w", err)
|
|
|
|
}
|
|
|
|
|
2020-03-26 02:50:56 +00:00
|
|
|
dst, err := st.index.StorageInfo(ctx, ID(PathByType(destIds, fileType)))
|
2020-03-25 18:21:53 +00:00
|
|
|
if err != nil {
|
|
|
|
return xerrors.Errorf("failed to get source storage info: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
if sst.ID == dst.ID {
|
|
|
|
log.Debugf("not moving %v(%d); src and dest are the same", s, fileType)
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
if sst.CanStore {
|
|
|
|
log.Debugf("not moving %v(%d); source supports storage", s, fileType)
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
log.Debugf("moving %v(%d) to storage: %s(se:%t; st:%t) -> %s(se:%t; st:%t)", s, fileType, sst.ID, sst.CanSeal, sst.CanStore, dst.ID, dst.CanSeal, dst.CanStore)
|
|
|
|
|
2020-03-26 02:50:56 +00:00
|
|
|
if err := st.index.StorageDropSector(ctx, ID(PathByType(srcIds, fileType)), s, fileType); err != nil {
|
2020-03-25 18:21:53 +00:00
|
|
|
return xerrors.Errorf("dropping source sector from index: %w", err)
|
|
|
|
}
|
|
|
|
|
2020-03-26 02:50:56 +00:00
|
|
|
if err := move(PathByType(src, fileType), PathByType(dest, fileType)); err != nil {
|
2020-03-25 18:21:53 +00:00
|
|
|
// TODO: attempt some recovery (check if src is still there, re-declare)
|
|
|
|
return xerrors.Errorf("moving sector %v(%d): %w", s, fileType, err)
|
|
|
|
}
|
|
|
|
|
2020-05-20 16:36:46 +00:00
|
|
|
if err := st.index.StorageDeclareSector(ctx, ID(PathByType(destIds, fileType)), s, fileType, true); err != nil {
|
2020-03-26 02:50:56 +00:00
|
|
|
return xerrors.Errorf("declare sector %d(t:%d) -> %s: %w", s, fileType, ID(PathByType(destIds, fileType)), err)
|
2020-03-25 18:21:53 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2020-03-23 22:43:38 +00:00
|
|
|
var errPathNotFound = xerrors.Errorf("fsstat: path not found")
|
|
|
|
|
2020-03-24 20:28:07 +00:00
|
|
|
func (st *Local) FsStat(ctx context.Context, id ID) (FsStat, error) {
|
2020-03-23 11:40:02 +00:00
|
|
|
st.localLk.RLock()
|
|
|
|
defer st.localLk.RUnlock()
|
|
|
|
|
|
|
|
p, ok := st.paths[id]
|
|
|
|
if !ok {
|
2020-03-23 22:43:38 +00:00
|
|
|
return FsStat{}, errPathNotFound
|
2020-03-23 11:40:02 +00:00
|
|
|
}
|
|
|
|
|
2020-05-20 16:36:46 +00:00
|
|
|
return st.localStorage.Stat(p.local)
|
2020-03-23 11:40:02 +00:00
|
|
|
}
|
2020-03-24 20:28:07 +00:00
|
|
|
|
|
|
|
var _ Store = &Local{}
|