2018-03-30 20:43:18 +00:00
|
|
|
package archiver
|
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
|
|
|
"encoding/json"
|
|
|
|
"os"
|
|
|
|
"path"
|
|
|
|
"runtime"
|
|
|
|
"sort"
|
|
|
|
"time"
|
|
|
|
|
|
|
|
"github.com/restic/restic/internal/debug"
|
|
|
|
"github.com/restic/restic/internal/errors"
|
|
|
|
"github.com/restic/restic/internal/fs"
|
|
|
|
"github.com/restic/restic/internal/restic"
|
2018-05-08 20:28:37 +00:00
|
|
|
tomb "gopkg.in/tomb.v2"
|
2018-03-30 20:43:18 +00:00
|
|
|
)
|
|
|
|
|
2018-07-31 15:25:25 +00:00
|
|
|
// SelectByNameFunc returns true for all items that should be included (files and
|
|
|
|
// dirs). If false is returned, files are ignored and dirs are not even walked.
|
|
|
|
type SelectByNameFunc func(item string) bool
|
|
|
|
|
2018-03-30 20:43:18 +00:00
|
|
|
// SelectFunc returns true for all items that should be included (files and
|
|
|
|
// dirs). If false is returned, files are ignored and dirs are not even walked.
|
|
|
|
type SelectFunc func(item string, fi os.FileInfo) bool
|
|
|
|
|
|
|
|
// ErrorFunc is called when an error during archiving occurs. When nil is
|
|
|
|
// returned, the archiver continues, otherwise it aborts and passes the error
|
|
|
|
// up the call stack.
|
|
|
|
type ErrorFunc func(file string, fi os.FileInfo, err error) error
|
|
|
|
|
|
|
|
// ItemStats collects some statistics about a particular file or directory.
|
|
|
|
type ItemStats struct {
|
|
|
|
DataBlobs int // number of new data blobs added for this item
|
|
|
|
DataSize uint64 // sum of the sizes of all new data blobs
|
|
|
|
TreeBlobs int // number of new tree blobs added for this item
|
|
|
|
TreeSize uint64 // sum of the sizes of all new tree blobs
|
|
|
|
}
|
|
|
|
|
|
|
|
// Add adds other to the current ItemStats.
|
|
|
|
func (s *ItemStats) Add(other ItemStats) {
|
|
|
|
s.DataBlobs += other.DataBlobs
|
|
|
|
s.DataSize += other.DataSize
|
|
|
|
s.TreeBlobs += other.TreeBlobs
|
|
|
|
s.TreeSize += other.TreeSize
|
|
|
|
}
|
|
|
|
|
|
|
|
// Archiver saves a directory structure to the repo.
|
|
|
|
type Archiver struct {
|
2018-07-31 15:25:25 +00:00
|
|
|
Repo restic.Repository
|
|
|
|
SelectByName SelectByNameFunc
|
|
|
|
Select SelectFunc
|
|
|
|
FS fs.FS
|
|
|
|
Options Options
|
2018-03-30 20:43:18 +00:00
|
|
|
|
|
|
|
blobSaver *BlobSaver
|
|
|
|
fileSaver *FileSaver
|
2018-04-30 13:13:03 +00:00
|
|
|
treeSaver *TreeSaver
|
2018-03-30 20:43:18 +00:00
|
|
|
|
|
|
|
// Error is called for all errors that occur during backup.
|
|
|
|
Error ErrorFunc
|
|
|
|
|
|
|
|
// CompleteItem is called for all files and dirs once they have been
|
|
|
|
// processed successfully. The parameter item contains the path as it will
|
|
|
|
// be in the snapshot after saving. s contains some statistics about this
|
|
|
|
// particular file/dir.
|
|
|
|
//
|
|
|
|
// CompleteItem may be called asynchronously from several different
|
|
|
|
// goroutines!
|
|
|
|
CompleteItem func(item string, previous, current *restic.Node, s ItemStats, d time.Duration)
|
|
|
|
|
|
|
|
// StartFile is called when a file is being processed by a worker.
|
|
|
|
StartFile func(filename string)
|
|
|
|
|
|
|
|
// CompleteBlob is called for all saved blobs for files.
|
|
|
|
CompleteBlob func(filename string, bytes uint64)
|
|
|
|
|
|
|
|
// WithAtime configures if the access time for files and directories should
|
|
|
|
// be saved. Enabling it may result in much metadata, so it's off by
|
|
|
|
// default.
|
2019-03-16 12:29:05 +00:00
|
|
|
WithAtime bool
|
2019-03-10 20:22:54 +00:00
|
|
|
IgnoreInode bool
|
2018-03-30 20:43:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Options is used to configure the archiver.
|
|
|
|
type Options struct {
|
|
|
|
// FileReadConcurrency sets how many files are read in concurrently. If
|
|
|
|
// it's set to zero, at most two files are read in concurrently (which
|
|
|
|
// turned out to be a good default for most situations).
|
|
|
|
FileReadConcurrency uint
|
|
|
|
|
|
|
|
// SaveBlobConcurrency sets how many blobs are hashed and saved
|
|
|
|
// concurrently. If it's set to zero, the default is the number of CPUs
|
|
|
|
// available in the system.
|
|
|
|
SaveBlobConcurrency uint
|
2018-04-30 13:13:03 +00:00
|
|
|
|
|
|
|
// SaveTreeConcurrency sets how many trees are marshalled and saved to the
|
|
|
|
// repo concurrently.
|
|
|
|
SaveTreeConcurrency uint
|
2018-03-30 20:43:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// ApplyDefaults returns a copy of o with the default options set for all unset
|
|
|
|
// fields.
|
|
|
|
func (o Options) ApplyDefaults() Options {
|
|
|
|
if o.FileReadConcurrency == 0 {
|
|
|
|
// two is a sweet spot for almost all situations. We've done some
|
|
|
|
// experiments documented here:
|
|
|
|
// https://github.com/borgbackup/borg/issues/3500
|
|
|
|
o.FileReadConcurrency = 2
|
|
|
|
}
|
|
|
|
|
|
|
|
if o.SaveBlobConcurrency == 0 {
|
|
|
|
o.SaveBlobConcurrency = uint(runtime.NumCPU())
|
|
|
|
}
|
|
|
|
|
2018-04-30 13:13:03 +00:00
|
|
|
if o.SaveTreeConcurrency == 0 {
|
|
|
|
// use a relatively high concurrency here, having multiple SaveTree
|
|
|
|
// workers is cheap
|
|
|
|
o.SaveTreeConcurrency = o.SaveBlobConcurrency * 20
|
|
|
|
}
|
|
|
|
|
2018-03-30 20:43:18 +00:00
|
|
|
return o
|
|
|
|
}
|
|
|
|
|
|
|
|
// New initializes a new archiver.
|
|
|
|
func New(repo restic.Repository, fs fs.FS, opts Options) *Archiver {
|
|
|
|
arch := &Archiver{
|
2018-07-31 15:25:25 +00:00
|
|
|
Repo: repo,
|
|
|
|
SelectByName: func(item string) bool { return true },
|
|
|
|
Select: func(item string, fi os.FileInfo) bool { return true },
|
|
|
|
FS: fs,
|
|
|
|
Options: opts.ApplyDefaults(),
|
2018-03-30 20:43:18 +00:00
|
|
|
|
|
|
|
CompleteItem: func(string, *restic.Node, *restic.Node, ItemStats, time.Duration) {},
|
|
|
|
StartFile: func(string) {},
|
|
|
|
CompleteBlob: func(string, uint64) {},
|
2019-03-10 20:22:54 +00:00
|
|
|
IgnoreInode: false,
|
2018-03-30 20:43:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return arch
|
|
|
|
}
|
|
|
|
|
2018-05-08 20:28:37 +00:00
|
|
|
// error calls arch.Error if it is set and the error is different from context.Canceled.
|
2018-03-30 20:43:18 +00:00
|
|
|
func (arch *Archiver) error(item string, fi os.FileInfo, err error) error {
|
|
|
|
if arch.Error == nil || err == nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2018-05-08 20:28:37 +00:00
|
|
|
if err == context.Canceled {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2018-03-30 20:43:18 +00:00
|
|
|
errf := arch.Error(item, fi, err)
|
|
|
|
if err != errf {
|
|
|
|
debug.Log("item %v: error was filtered by handler, before: %q, after: %v", item, err, errf)
|
|
|
|
}
|
|
|
|
return errf
|
|
|
|
}
|
|
|
|
|
|
|
|
// saveTree stores a tree in the repo. It checks the index and the known blobs
|
|
|
|
// before saving anything.
|
|
|
|
func (arch *Archiver) saveTree(ctx context.Context, t *restic.Tree) (restic.ID, ItemStats, error) {
|
|
|
|
var s ItemStats
|
|
|
|
buf, err := json.Marshal(t)
|
|
|
|
if err != nil {
|
|
|
|
return restic.ID{}, s, errors.Wrap(err, "MarshalJSON")
|
|
|
|
}
|
|
|
|
|
|
|
|
// append a newline so that the data is always consistent (json.Encoder
|
|
|
|
// adds a newline after each object)
|
|
|
|
buf = append(buf, '\n')
|
|
|
|
|
2018-04-29 13:34:41 +00:00
|
|
|
b := &Buffer{Data: buf}
|
2018-03-30 20:43:18 +00:00
|
|
|
res := arch.blobSaver.Save(ctx, restic.TreeBlob, b)
|
|
|
|
|
2018-05-08 20:28:37 +00:00
|
|
|
res.Wait(ctx)
|
2018-03-30 20:43:18 +00:00
|
|
|
if !res.Known() {
|
|
|
|
s.TreeBlobs++
|
|
|
|
s.TreeSize += uint64(len(buf))
|
|
|
|
}
|
|
|
|
return res.ID(), s, nil
|
|
|
|
}
|
|
|
|
|
2020-05-16 06:05:26 +00:00
|
|
|
// nodeFromFileInfo returns the restic node from an os.FileInfo.
|
2018-03-30 20:43:18 +00:00
|
|
|
func (arch *Archiver) nodeFromFileInfo(filename string, fi os.FileInfo) (*restic.Node, error) {
|
|
|
|
node, err := restic.NodeFromFileInfo(filename, fi)
|
|
|
|
if !arch.WithAtime {
|
|
|
|
node.AccessTime = node.ModTime
|
|
|
|
}
|
|
|
|
return node, errors.Wrap(err, "NodeFromFileInfo")
|
|
|
|
}
|
|
|
|
|
|
|
|
// loadSubtree tries to load the subtree referenced by node. In case of an error, nil is returned.
|
|
|
|
func (arch *Archiver) loadSubtree(ctx context.Context, node *restic.Node) *restic.Tree {
|
|
|
|
if node == nil || node.Type != "dir" || node.Subtree == nil {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
tree, err := arch.Repo.LoadTree(ctx, *node.Subtree)
|
|
|
|
if err != nil {
|
|
|
|
debug.Log("unable to load tree %v: %v", node.Subtree.Str(), err)
|
|
|
|
// TODO: handle error
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
return tree
|
|
|
|
}
|
|
|
|
|
|
|
|
// SaveDir stores a directory in the repo and returns the node. snPath is the
|
|
|
|
// path within the current snapshot.
|
2020-04-22 20:23:02 +00:00
|
|
|
func (arch *Archiver) SaveDir(ctx context.Context, snPath string, fi os.FileInfo, dir string, previous *restic.Tree, complete CompleteFunc) (d FutureTree, err error) {
|
2018-03-30 20:43:18 +00:00
|
|
|
debug.Log("%v %v", snPath, dir)
|
|
|
|
|
|
|
|
treeNode, err := arch.nodeFromFileInfo(dir, fi)
|
|
|
|
if err != nil {
|
2018-04-30 13:13:03 +00:00
|
|
|
return FutureTree{}, err
|
2018-03-30 20:43:18 +00:00
|
|
|
}
|
|
|
|
|
2020-02-17 08:22:32 +00:00
|
|
|
names, err := readdirnames(arch.FS, dir, fs.O_NOFOLLOW)
|
2018-03-30 20:43:18 +00:00
|
|
|
if err != nil {
|
2018-04-30 13:13:03 +00:00
|
|
|
return FutureTree{}, err
|
2018-03-30 20:43:18 +00:00
|
|
|
}
|
2020-02-17 08:22:32 +00:00
|
|
|
sort.Strings(names)
|
2018-03-30 20:43:18 +00:00
|
|
|
|
2018-04-30 13:13:03 +00:00
|
|
|
nodes := make([]FutureNode, 0, len(names))
|
2018-03-30 20:43:18 +00:00
|
|
|
|
|
|
|
for _, name := range names {
|
2018-05-08 20:28:37 +00:00
|
|
|
// test if context has been cancelled
|
|
|
|
if ctx.Err() != nil {
|
2018-05-12 21:54:20 +00:00
|
|
|
debug.Log("context has been cancelled, aborting")
|
2018-05-08 20:28:37 +00:00
|
|
|
return FutureTree{}, ctx.Err()
|
|
|
|
}
|
|
|
|
|
2018-03-30 20:43:18 +00:00
|
|
|
pathname := arch.FS.Join(dir, name)
|
|
|
|
oldNode := previous.Find(name)
|
|
|
|
snItem := join(snPath, name)
|
|
|
|
fn, excluded, err := arch.Save(ctx, snItem, pathname, oldNode)
|
|
|
|
|
|
|
|
// return error early if possible
|
|
|
|
if err != nil {
|
|
|
|
err = arch.error(pathname, fi, err)
|
|
|
|
if err == nil {
|
|
|
|
// ignore error
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
2018-04-30 13:13:03 +00:00
|
|
|
return FutureTree{}, err
|
2018-03-30 20:43:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if excluded {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
2018-04-30 13:13:03 +00:00
|
|
|
nodes = append(nodes, fn)
|
2018-03-30 20:43:18 +00:00
|
|
|
}
|
|
|
|
|
2020-04-22 20:23:02 +00:00
|
|
|
ft := arch.treeSaver.Save(ctx, snPath, treeNode, nodes, complete)
|
2018-03-30 20:43:18 +00:00
|
|
|
|
2018-04-30 13:13:03 +00:00
|
|
|
return ft, nil
|
2018-03-30 20:43:18 +00:00
|
|
|
}
|
|
|
|
|
2018-04-30 13:13:03 +00:00
|
|
|
// FutureNode holds a reference to a node, FutureFile, or FutureTree.
|
2018-03-30 20:43:18 +00:00
|
|
|
type FutureNode struct {
|
|
|
|
snPath, target string
|
|
|
|
|
|
|
|
// kept to call the error callback function
|
|
|
|
absTarget string
|
|
|
|
fi os.FileInfo
|
|
|
|
|
|
|
|
node *restic.Node
|
|
|
|
stats ItemStats
|
|
|
|
err error
|
|
|
|
|
|
|
|
isFile bool
|
|
|
|
file FutureFile
|
2018-05-12 19:59:58 +00:00
|
|
|
isTree bool
|
|
|
|
tree FutureTree
|
2018-03-30 20:43:18 +00:00
|
|
|
}
|
|
|
|
|
2018-04-30 13:13:03 +00:00
|
|
|
func (fn *FutureNode) wait(ctx context.Context) {
|
|
|
|
switch {
|
|
|
|
case fn.isFile:
|
2018-03-30 20:43:18 +00:00
|
|
|
// wait for and collect the data for the file
|
2018-05-12 19:40:31 +00:00
|
|
|
fn.file.Wait(ctx)
|
2018-03-30 20:43:18 +00:00
|
|
|
fn.node = fn.file.Node()
|
|
|
|
fn.err = fn.file.Err()
|
|
|
|
fn.stats = fn.file.Stats()
|
2018-04-30 13:13:03 +00:00
|
|
|
|
|
|
|
// ensure the other stuff can be garbage-collected
|
|
|
|
fn.file = FutureFile{}
|
|
|
|
fn.isFile = false
|
|
|
|
|
2018-05-12 19:59:58 +00:00
|
|
|
case fn.isTree:
|
2018-04-30 13:13:03 +00:00
|
|
|
// wait for and collect the data for the dir
|
2018-05-12 19:59:58 +00:00
|
|
|
fn.tree.Wait(ctx)
|
|
|
|
fn.node = fn.tree.Node()
|
|
|
|
fn.stats = fn.tree.Stats()
|
2018-04-30 13:13:03 +00:00
|
|
|
|
|
|
|
// ensure the other stuff can be garbage-collected
|
2018-05-12 19:59:58 +00:00
|
|
|
fn.tree = FutureTree{}
|
|
|
|
fn.isTree = false
|
2018-03-30 20:43:18 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-07-09 20:35:04 +00:00
|
|
|
// allBlobsPresent checks if all blobs (contents) of the given node are
|
|
|
|
// present in the index.
|
|
|
|
func (arch *Archiver) allBlobsPresent(previous *restic.Node) bool {
|
|
|
|
// check if all blobs are contained in index
|
|
|
|
for _, id := range previous.Content {
|
|
|
|
if !arch.Repo.Index().Has(id, restic.DataBlob) {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
2018-03-30 20:43:18 +00:00
|
|
|
// Save saves a target (file or directory) to the repo. If the item is
|
2018-07-31 15:25:25 +00:00
|
|
|
// excluded, this function returns a nil node and error, with excluded set to
|
2018-05-12 19:59:38 +00:00
|
|
|
// true.
|
2018-03-30 20:43:18 +00:00
|
|
|
//
|
2018-07-31 15:25:25 +00:00
|
|
|
// Errors and completion needs to be handled by the caller.
|
2018-03-30 20:43:18 +00:00
|
|
|
//
|
|
|
|
// snPath is the path within the current snapshot.
|
|
|
|
func (arch *Archiver) Save(ctx context.Context, snPath, target string, previous *restic.Node) (fn FutureNode, excluded bool, err error) {
|
2018-04-30 13:13:03 +00:00
|
|
|
start := time.Now()
|
|
|
|
|
2018-03-30 20:43:18 +00:00
|
|
|
fn = FutureNode{
|
|
|
|
snPath: snPath,
|
|
|
|
target: target,
|
|
|
|
}
|
|
|
|
|
|
|
|
debug.Log("%v target %q, previous %v", snPath, target, previous)
|
|
|
|
abstarget, err := arch.FS.Abs(target)
|
|
|
|
if err != nil {
|
|
|
|
return FutureNode{}, false, err
|
|
|
|
}
|
|
|
|
|
|
|
|
fn.absTarget = abstarget
|
|
|
|
|
2018-07-31 15:25:25 +00:00
|
|
|
// exclude files by path before running Lstat to reduce number of lstat calls
|
|
|
|
if !arch.SelectByName(abstarget) {
|
|
|
|
debug.Log("%v is excluded by path", target)
|
|
|
|
return FutureNode{}, true, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// get file info and run remaining select functions that require file information
|
archiver: Use lstat before open/fstat
The previous code tried to be as efficient as possible and only do a
single open() on an item to save, and then fstat() on the fd to find out
what the item is (file, dir, other). For normal files, it would then
start reading the data without opening the file again, so it could not
be exchanged for e.g. a symlink.
This behavior starts the watchdog on my machine when /dev is saved
with restic, and after a few seconds, the machine reboots.
This commit reverts the behavior to the strategy the old archiver code
used: run lstat(), then decide what to do. For normal files, open the
file and then run fstat() on the fd to verify it's still a normal file,
then start reading the data.
The downside is that for normal files we now do two stat() calls
(lstat+fstat) instead of only one. On the upside, this does not start
the watchdog. :)
2018-05-01 21:05:50 +00:00
|
|
|
fi, err := arch.FS.Lstat(target)
|
2018-03-30 20:43:18 +00:00
|
|
|
if !arch.Select(abstarget, fi) {
|
|
|
|
debug.Log("%v is excluded", target)
|
|
|
|
return FutureNode{}, true, nil
|
|
|
|
}
|
|
|
|
|
archiver: Use lstat before open/fstat
The previous code tried to be as efficient as possible and only do a
single open() on an item to save, and then fstat() on the fd to find out
what the item is (file, dir, other). For normal files, it would then
start reading the data without opening the file again, so it could not
be exchanged for e.g. a symlink.
This behavior starts the watchdog on my machine when /dev is saved
with restic, and after a few seconds, the machine reboots.
This commit reverts the behavior to the strategy the old archiver code
used: run lstat(), then decide what to do. For normal files, open the
file and then run fstat() on the fd to verify it's still a normal file,
then start reading the data.
The downside is that for normal files we now do two stat() calls
(lstat+fstat) instead of only one. On the upside, this does not start
the watchdog. :)
2018-05-01 21:05:50 +00:00
|
|
|
if err != nil {
|
|
|
|
debug.Log("lstat() for %v returned error: %v", target, err)
|
|
|
|
err = arch.error(abstarget, fi, err)
|
|
|
|
if err != nil {
|
|
|
|
return FutureNode{}, false, errors.Wrap(err, "Lstat")
|
2018-03-30 20:43:18 +00:00
|
|
|
}
|
archiver: Use lstat before open/fstat
The previous code tried to be as efficient as possible and only do a
single open() on an item to save, and then fstat() on the fd to find out
what the item is (file, dir, other). For normal files, it would then
start reading the data without opening the file again, so it could not
be exchanged for e.g. a symlink.
This behavior starts the watchdog on my machine when /dev is saved
with restic, and after a few seconds, the machine reboots.
This commit reverts the behavior to the strategy the old archiver code
used: run lstat(), then decide what to do. For normal files, open the
file and then run fstat() on the fd to verify it's still a normal file,
then start reading the data.
The downside is that for normal files we now do two stat() calls
(lstat+fstat) instead of only one. On the upside, this does not start
the watchdog. :)
2018-05-01 21:05:50 +00:00
|
|
|
return FutureNode{}, true, nil
|
2018-03-30 20:43:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
switch {
|
|
|
|
case fs.IsRegularFile(fi):
|
|
|
|
debug.Log(" %v regular file", target)
|
|
|
|
start := time.Now()
|
|
|
|
|
archiver: Use lstat before open/fstat
The previous code tried to be as efficient as possible and only do a
single open() on an item to save, and then fstat() on the fd to find out
what the item is (file, dir, other). For normal files, it would then
start reading the data without opening the file again, so it could not
be exchanged for e.g. a symlink.
This behavior starts the watchdog on my machine when /dev is saved
with restic, and after a few seconds, the machine reboots.
This commit reverts the behavior to the strategy the old archiver code
used: run lstat(), then decide what to do. For normal files, open the
file and then run fstat() on the fd to verify it's still a normal file,
then start reading the data.
The downside is that for normal files we now do two stat() calls
(lstat+fstat) instead of only one. On the upside, this does not start
the watchdog. :)
2018-05-01 21:05:50 +00:00
|
|
|
// reopen file and do an fstat() on the open file to check it is still
|
|
|
|
// a file (and has not been exchanged for e.g. a symlink)
|
2018-05-20 14:05:53 +00:00
|
|
|
file, err := arch.FS.OpenFile(target, fs.O_RDONLY|fs.O_NOFOLLOW, 0)
|
archiver: Use lstat before open/fstat
The previous code tried to be as efficient as possible and only do a
single open() on an item to save, and then fstat() on the fd to find out
what the item is (file, dir, other). For normal files, it would then
start reading the data without opening the file again, so it could not
be exchanged for e.g. a symlink.
This behavior starts the watchdog on my machine when /dev is saved
with restic, and after a few seconds, the machine reboots.
This commit reverts the behavior to the strategy the old archiver code
used: run lstat(), then decide what to do. For normal files, open the
file and then run fstat() on the fd to verify it's still a normal file,
then start reading the data.
The downside is that for normal files we now do two stat() calls
(lstat+fstat) instead of only one. On the upside, this does not start
the watchdog. :)
2018-05-01 21:05:50 +00:00
|
|
|
if err != nil {
|
|
|
|
debug.Log("Openfile() for %v returned error: %v", target, err)
|
|
|
|
err = arch.error(abstarget, fi, err)
|
|
|
|
if err != nil {
|
|
|
|
return FutureNode{}, false, errors.Wrap(err, "Lstat")
|
|
|
|
}
|
|
|
|
return FutureNode{}, true, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
fi, err = file.Stat()
|
|
|
|
if err != nil {
|
|
|
|
debug.Log("stat() on opened file %v returned error: %v", target, err)
|
|
|
|
_ = file.Close()
|
|
|
|
err = arch.error(abstarget, fi, err)
|
|
|
|
if err != nil {
|
|
|
|
return FutureNode{}, false, errors.Wrap(err, "Lstat")
|
|
|
|
}
|
|
|
|
return FutureNode{}, true, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// make sure it's still a file
|
|
|
|
if !fs.IsRegularFile(fi) {
|
|
|
|
err = errors.Errorf("file %v changed type, refusing to archive")
|
2020-02-07 21:14:50 +00:00
|
|
|
_ = file.Close()
|
archiver: Use lstat before open/fstat
The previous code tried to be as efficient as possible and only do a
single open() on an item to save, and then fstat() on the fd to find out
what the item is (file, dir, other). For normal files, it would then
start reading the data without opening the file again, so it could not
be exchanged for e.g. a symlink.
This behavior starts the watchdog on my machine when /dev is saved
with restic, and after a few seconds, the machine reboots.
This commit reverts the behavior to the strategy the old archiver code
used: run lstat(), then decide what to do. For normal files, open the
file and then run fstat() on the fd to verify it's still a normal file,
then start reading the data.
The downside is that for normal files we now do two stat() calls
(lstat+fstat) instead of only one. On the upside, this does not start
the watchdog. :)
2018-05-01 21:05:50 +00:00
|
|
|
err = arch.error(abstarget, fi, err)
|
|
|
|
if err != nil {
|
|
|
|
return FutureNode{}, false, err
|
|
|
|
}
|
|
|
|
return FutureNode{}, true, nil
|
|
|
|
}
|
|
|
|
|
2019-04-24 13:07:26 +00:00
|
|
|
// use previous list of blobs if the file hasn't changed
|
2019-03-10 20:22:54 +00:00
|
|
|
if previous != nil && !fileChanged(fi, previous, arch.IgnoreInode) {
|
2020-07-09 20:35:04 +00:00
|
|
|
if arch.allBlobsPresent(previous) {
|
|
|
|
debug.Log("%v hasn't changed, using old list of blobs", target)
|
|
|
|
arch.CompleteItem(snPath, previous, previous, ItemStats{}, time.Since(start))
|
|
|
|
arch.CompleteBlob(snPath, previous.Size)
|
|
|
|
fn.node, err = arch.nodeFromFileInfo(target, fi)
|
|
|
|
if err != nil {
|
|
|
|
return FutureNode{}, false, err
|
|
|
|
}
|
|
|
|
|
|
|
|
// copy list of blobs
|
|
|
|
fn.node.Content = previous.Content
|
|
|
|
|
|
|
|
_ = file.Close()
|
|
|
|
return fn, false, nil
|
2019-04-24 13:07:26 +00:00
|
|
|
}
|
2020-07-28 20:19:21 +00:00
|
|
|
|
|
|
|
debug.Log("%v hasn't changed, but contents are missing!", target)
|
|
|
|
// There are contents missing - inform user!
|
|
|
|
err := errors.Errorf("parts of %v not found in the repository index; storing the file again", target)
|
|
|
|
arch.error(abstarget, fi, err)
|
2018-03-30 20:43:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
fn.isFile = true
|
|
|
|
// Save will close the file, we don't need to do that
|
|
|
|
fn.file = arch.fileSaver.Save(ctx, snPath, file, fi, func() {
|
|
|
|
arch.StartFile(snPath)
|
|
|
|
}, func(node *restic.Node, stats ItemStats) {
|
|
|
|
arch.CompleteItem(snPath, previous, node, stats, time.Since(start))
|
|
|
|
})
|
|
|
|
|
|
|
|
case fi.IsDir():
|
|
|
|
debug.Log(" %v dir", target)
|
|
|
|
|
|
|
|
snItem := snPath + "/"
|
|
|
|
start := time.Now()
|
|
|
|
oldSubtree := arch.loadSubtree(ctx, previous)
|
2018-04-30 13:13:03 +00:00
|
|
|
|
2018-05-12 19:59:58 +00:00
|
|
|
fn.isTree = true
|
2020-04-22 20:23:02 +00:00
|
|
|
fn.tree, err = arch.SaveDir(ctx, snPath, fi, target, oldSubtree,
|
|
|
|
func(node *restic.Node, stats ItemStats) {
|
|
|
|
arch.CompleteItem(snItem, previous, node, stats, time.Since(start))
|
|
|
|
})
|
|
|
|
if err != nil {
|
2018-05-12 21:08:00 +00:00
|
|
|
debug.Log("SaveDir for %v returned error: %v", snPath, err)
|
2018-03-30 20:43:18 +00:00
|
|
|
return FutureNode{}, false, err
|
|
|
|
}
|
|
|
|
|
|
|
|
case fi.Mode()&os.ModeSocket > 0:
|
|
|
|
debug.Log(" %v is a socket, ignoring", target)
|
|
|
|
return FutureNode{}, true, nil
|
|
|
|
|
|
|
|
default:
|
|
|
|
debug.Log(" %v other", target)
|
|
|
|
|
|
|
|
fn.node, err = arch.nodeFromFileInfo(target, fi)
|
|
|
|
if err != nil {
|
|
|
|
return FutureNode{}, false, err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-04-30 13:13:03 +00:00
|
|
|
debug.Log("return after %.3f", time.Since(start).Seconds())
|
|
|
|
|
2018-03-30 20:43:18 +00:00
|
|
|
return fn, false, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// fileChanged returns true if the file's content has changed since the node
|
|
|
|
// was created.
|
2019-03-10 20:22:54 +00:00
|
|
|
func fileChanged(fi os.FileInfo, node *restic.Node, ignoreInode bool) bool {
|
2018-03-30 20:43:18 +00:00
|
|
|
if node == nil {
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
|
|
|
// check type change
|
|
|
|
if node.Type != "file" {
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
|
|
|
// check modification timestamp
|
|
|
|
if !fi.ModTime().Equal(node.ModTime) {
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
2019-03-20 01:27:37 +00:00
|
|
|
// check status change timestamp
|
2018-03-30 20:43:18 +00:00
|
|
|
extFI := fs.ExtendedStat(fi)
|
2019-04-24 03:27:38 +00:00
|
|
|
if !ignoreInode && !extFI.ChangeTime.Equal(node.ChangeTime) {
|
2019-03-20 01:27:37 +00:00
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
|
|
|
// check size
|
2018-03-30 20:43:18 +00:00
|
|
|
if uint64(fi.Size()) != node.Size || uint64(extFI.Size) != node.Size {
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
|
|
|
// check inode
|
2019-03-10 20:22:54 +00:00
|
|
|
if !ignoreInode && node.Inode != extFI.Inode {
|
2018-03-30 20:43:18 +00:00
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
|
|
|
// join returns all elements separated with a forward slash.
|
|
|
|
func join(elem ...string) string {
|
|
|
|
return path.Join(elem...)
|
|
|
|
}
|
|
|
|
|
|
|
|
// statDir returns the file info for the directory. Symbolic links are
|
|
|
|
// resolved. If the target directory is not a directory, an error is returned.
|
|
|
|
func (arch *Archiver) statDir(dir string) (os.FileInfo, error) {
|
|
|
|
fi, err := arch.FS.Stat(dir)
|
|
|
|
if err != nil {
|
|
|
|
return nil, errors.Wrap(err, "Lstat")
|
|
|
|
}
|
|
|
|
|
|
|
|
tpe := fi.Mode() & (os.ModeType | os.ModeCharDevice)
|
|
|
|
if tpe != os.ModeDir {
|
|
|
|
return fi, errors.Errorf("path is not a directory: %v", dir)
|
|
|
|
}
|
|
|
|
|
|
|
|
return fi, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// SaveTree stores a Tree in the repo, returned is the tree. snPath is the path
|
|
|
|
// within the current snapshot.
|
|
|
|
func (arch *Archiver) SaveTree(ctx context.Context, snPath string, atree *Tree, previous *restic.Tree) (*restic.Tree, error) {
|
|
|
|
debug.Log("%v (%v nodes), parent %v", snPath, len(atree.Nodes), previous)
|
|
|
|
|
|
|
|
tree := restic.NewTree()
|
|
|
|
|
|
|
|
futureNodes := make(map[string]FutureNode)
|
|
|
|
|
2018-05-12 21:07:16 +00:00
|
|
|
// iterate over the nodes of atree in lexicographic (=deterministic) order
|
|
|
|
names := make([]string, 0, len(atree.Nodes))
|
|
|
|
for name := range atree.Nodes {
|
|
|
|
names = append(names, name)
|
|
|
|
}
|
2020-02-17 08:30:00 +00:00
|
|
|
sort.Strings(names)
|
2018-05-12 21:07:16 +00:00
|
|
|
|
|
|
|
for _, name := range names {
|
|
|
|
subatree := atree.Nodes[name]
|
|
|
|
|
2018-05-08 20:28:37 +00:00
|
|
|
// test if context has been cancelled
|
|
|
|
if ctx.Err() != nil {
|
|
|
|
return nil, ctx.Err()
|
|
|
|
}
|
2018-03-30 20:43:18 +00:00
|
|
|
|
|
|
|
// this is a leaf node
|
|
|
|
if subatree.Path != "" {
|
|
|
|
fn, excluded, err := arch.Save(ctx, join(snPath, name), subatree.Path, previous.Find(name))
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
err = arch.error(subatree.Path, fn.fi, err)
|
|
|
|
if err == nil {
|
|
|
|
// ignore error
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
if !excluded {
|
|
|
|
futureNodes[name] = fn
|
|
|
|
}
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
snItem := join(snPath, name) + "/"
|
|
|
|
start := time.Now()
|
|
|
|
|
|
|
|
oldNode := previous.Find(name)
|
|
|
|
oldSubtree := arch.loadSubtree(ctx, oldNode)
|
|
|
|
|
|
|
|
// not a leaf node, archive subtree
|
|
|
|
subtree, err := arch.SaveTree(ctx, join(snPath, name), &subatree, oldSubtree)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
id, nodeStats, err := arch.saveTree(ctx, subtree)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
if subatree.FileInfoPath == "" {
|
|
|
|
return nil, errors.Errorf("FileInfoPath for %v/%v is empty", snPath, name)
|
|
|
|
}
|
|
|
|
|
|
|
|
debug.Log("%v, saved subtree %v as %v", snPath, subtree, id.Str())
|
|
|
|
|
|
|
|
fi, err := arch.statDir(subatree.FileInfoPath)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
debug.Log("%v, dir node data loaded from %v", snPath, subatree.FileInfoPath)
|
|
|
|
|
|
|
|
node, err := arch.nodeFromFileInfo(subatree.FileInfoPath, fi)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
node.Name = name
|
|
|
|
node.Subtree = &id
|
|
|
|
|
|
|
|
err = tree.Insert(node)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
arch.CompleteItem(snItem, oldNode, node, nodeStats, time.Since(start))
|
|
|
|
}
|
|
|
|
|
2018-04-30 13:13:03 +00:00
|
|
|
debug.Log("waiting on %d nodes", len(futureNodes))
|
|
|
|
|
2018-03-30 20:43:18 +00:00
|
|
|
// process all futures
|
|
|
|
for name, fn := range futureNodes {
|
2018-04-30 13:13:03 +00:00
|
|
|
fn.wait(ctx)
|
2018-03-30 20:43:18 +00:00
|
|
|
|
|
|
|
// return the error, or ignore it
|
|
|
|
if fn.err != nil {
|
|
|
|
fn.err = arch.error(fn.target, fn.fi, fn.err)
|
|
|
|
if fn.err == nil {
|
|
|
|
// ignore error
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil, fn.err
|
|
|
|
}
|
|
|
|
|
|
|
|
// when the error is ignored, the node could not be saved, so ignore it
|
|
|
|
if fn.node == nil {
|
|
|
|
debug.Log("%v excluded: %v", fn.snPath, fn.target)
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
fn.node.Name = name
|
|
|
|
|
|
|
|
err := tree.Insert(fn.node)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return tree, nil
|
|
|
|
}
|
|
|
|
|
2020-02-17 08:22:32 +00:00
|
|
|
// flags are passed to fs.OpenFile. O_RDONLY is implied.
|
|
|
|
func readdirnames(filesystem fs.FS, dir string, flags int) ([]string, error) {
|
|
|
|
f, err := filesystem.OpenFile(dir, fs.O_RDONLY|flags, 0)
|
2018-03-30 20:43:18 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, errors.Wrap(err, "Open")
|
|
|
|
}
|
|
|
|
|
|
|
|
entries, err := f.Readdirnames(-1)
|
|
|
|
if err != nil {
|
|
|
|
_ = f.Close()
|
2018-05-15 09:03:33 +00:00
|
|
|
return nil, errors.Wrapf(err, "Readdirnames %v failed", dir)
|
2018-03-30 20:43:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
err = f.Close()
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
return entries, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// resolveRelativeTargets replaces targets that only contain relative
|
|
|
|
// directories ("." or "../../") with the contents of the directory. Each
|
|
|
|
// element of target is processed with fs.Clean().
|
2020-02-17 08:22:32 +00:00
|
|
|
func resolveRelativeTargets(filesys fs.FS, targets []string) ([]string, error) {
|
2018-03-30 20:43:18 +00:00
|
|
|
debug.Log("targets before resolving: %v", targets)
|
|
|
|
result := make([]string, 0, len(targets))
|
|
|
|
for _, target := range targets {
|
2020-02-17 08:22:32 +00:00
|
|
|
target = filesys.Clean(target)
|
|
|
|
pc, _ := pathComponents(filesys, target, false)
|
2018-03-30 20:43:18 +00:00
|
|
|
if len(pc) > 0 {
|
|
|
|
result = append(result, target)
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
debug.Log("replacing %q with readdir(%q)", target, target)
|
2020-02-17 08:22:32 +00:00
|
|
|
entries, err := readdirnames(filesys, target, fs.O_NOFOLLOW)
|
2018-03-30 20:43:18 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
2020-02-17 08:22:32 +00:00
|
|
|
sort.Strings(entries)
|
2018-03-30 20:43:18 +00:00
|
|
|
|
|
|
|
for _, name := range entries {
|
2020-02-17 08:22:32 +00:00
|
|
|
result = append(result, filesys.Join(target, name))
|
2018-03-30 20:43:18 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
debug.Log("targets after resolving: %v", result)
|
|
|
|
return result, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// SnapshotOptions collect attributes for a new snapshot.
|
|
|
|
type SnapshotOptions struct {
|
|
|
|
Tags []string
|
|
|
|
Hostname string
|
|
|
|
Excludes []string
|
|
|
|
Time time.Time
|
|
|
|
ParentSnapshot restic.ID
|
|
|
|
}
|
|
|
|
|
|
|
|
// loadParentTree loads a tree referenced by snapshot id. If id is null, nil is returned.
|
|
|
|
func (arch *Archiver) loadParentTree(ctx context.Context, snapshotID restic.ID) *restic.Tree {
|
|
|
|
if snapshotID.IsNull() {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
debug.Log("load parent snapshot %v", snapshotID)
|
|
|
|
sn, err := restic.LoadSnapshot(ctx, arch.Repo, snapshotID)
|
|
|
|
if err != nil {
|
|
|
|
debug.Log("unable to load snapshot %v: %v", snapshotID, err)
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
if sn.Tree == nil {
|
|
|
|
debug.Log("snapshot %v has empty tree %v", snapshotID)
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
debug.Log("load parent tree %v", *sn.Tree)
|
|
|
|
tree, err := arch.Repo.LoadTree(ctx, *sn.Tree)
|
|
|
|
if err != nil {
|
|
|
|
debug.Log("unable to load tree %v: %v", *sn.Tree, err)
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
return tree
|
|
|
|
}
|
|
|
|
|
|
|
|
// runWorkers starts the worker pools, which are stopped when the context is cancelled.
|
2018-05-08 20:28:37 +00:00
|
|
|
func (arch *Archiver) runWorkers(ctx context.Context, t *tomb.Tomb) {
|
|
|
|
arch.blobSaver = NewBlobSaver(ctx, t, arch.Repo, arch.Options.SaveBlobConcurrency)
|
2018-04-30 13:13:03 +00:00
|
|
|
|
2018-05-08 20:28:37 +00:00
|
|
|
arch.fileSaver = NewFileSaver(ctx, t,
|
2018-05-12 19:40:31 +00:00
|
|
|
arch.blobSaver.Save,
|
2018-04-29 11:20:12 +00:00
|
|
|
arch.Repo.Config().ChunkerPolynomial,
|
|
|
|
arch.Options.FileReadConcurrency, arch.Options.SaveBlobConcurrency)
|
2018-03-30 20:43:18 +00:00
|
|
|
arch.fileSaver.CompleteBlob = arch.CompleteBlob
|
|
|
|
arch.fileSaver.NodeFromFileInfo = arch.nodeFromFileInfo
|
2018-04-30 13:13:03 +00:00
|
|
|
|
2018-05-12 19:40:31 +00:00
|
|
|
arch.treeSaver = NewTreeSaver(ctx, t, arch.Options.SaveTreeConcurrency, arch.saveTree, arch.Error)
|
2018-03-30 20:43:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Snapshot saves several targets and returns a snapshot.
|
|
|
|
func (arch *Archiver) Snapshot(ctx context.Context, targets []string, opts SnapshotOptions) (*restic.Snapshot, restic.ID, error) {
|
|
|
|
cleanTargets, err := resolveRelativeTargets(arch.FS, targets)
|
|
|
|
if err != nil {
|
|
|
|
return nil, restic.ID{}, err
|
|
|
|
}
|
|
|
|
|
|
|
|
atree, err := NewTree(arch.FS, cleanTargets)
|
|
|
|
if err != nil {
|
|
|
|
return nil, restic.ID{}, err
|
|
|
|
}
|
|
|
|
|
2018-05-08 20:28:37 +00:00
|
|
|
var t tomb.Tomb
|
|
|
|
wctx := t.Context(ctx)
|
|
|
|
|
|
|
|
arch.runWorkers(wctx, &t)
|
|
|
|
|
2018-03-30 20:43:18 +00:00
|
|
|
start := time.Now()
|
2018-05-08 20:28:37 +00:00
|
|
|
|
|
|
|
debug.Log("starting snapshot")
|
|
|
|
rootTreeID, stats, err := func() (restic.ID, ItemStats, error) {
|
|
|
|
tree, err := arch.SaveTree(wctx, "/", atree, arch.loadParentTree(wctx, opts.ParentSnapshot))
|
|
|
|
if err != nil {
|
|
|
|
return restic.ID{}, ItemStats{}, err
|
|
|
|
}
|
|
|
|
|
2018-05-20 13:58:55 +00:00
|
|
|
if len(tree.Nodes) == 0 {
|
|
|
|
return restic.ID{}, ItemStats{}, errors.New("snapshot is empty")
|
|
|
|
}
|
|
|
|
|
2018-05-08 20:28:37 +00:00
|
|
|
return arch.saveTree(wctx, tree)
|
|
|
|
}()
|
|
|
|
debug.Log("saved tree, error: %v", err)
|
|
|
|
|
|
|
|
t.Kill(nil)
|
|
|
|
werr := t.Wait()
|
2018-05-12 21:54:20 +00:00
|
|
|
debug.Log("err is %v, werr is %v", err, werr)
|
|
|
|
if err == nil || errors.Cause(err) == context.Canceled {
|
2018-05-08 20:28:37 +00:00
|
|
|
err = werr
|
2018-03-30 20:43:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if err != nil {
|
2018-05-08 20:28:37 +00:00
|
|
|
debug.Log("error while saving tree: %v", err)
|
2018-03-30 20:43:18 +00:00
|
|
|
return nil, restic.ID{}, err
|
|
|
|
}
|
|
|
|
|
|
|
|
arch.CompleteItem("/", nil, nil, stats, time.Since(start))
|
|
|
|
|
|
|
|
err = arch.Repo.Flush(ctx)
|
|
|
|
if err != nil {
|
|
|
|
return nil, restic.ID{}, err
|
|
|
|
}
|
|
|
|
|
|
|
|
sn, err := restic.NewSnapshot(targets, opts.Tags, opts.Hostname, opts.Time)
|
2020-02-12 21:37:37 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, restic.ID{}, err
|
|
|
|
}
|
|
|
|
|
2018-03-30 20:43:18 +00:00
|
|
|
sn.Excludes = opts.Excludes
|
|
|
|
if !opts.ParentSnapshot.IsNull() {
|
|
|
|
id := opts.ParentSnapshot
|
|
|
|
sn.Parent = &id
|
|
|
|
}
|
|
|
|
sn.Tree = &rootTreeID
|
|
|
|
|
|
|
|
id, err := arch.Repo.SaveJSONUnpacked(ctx, restic.SnapshotFile, sn)
|
|
|
|
if err != nil {
|
|
|
|
return nil, restic.ID{}, err
|
|
|
|
}
|
|
|
|
|
|
|
|
return sn, id, nil
|
|
|
|
}
|