Compare commits

...

8 Commits

Author SHA1 Message Date
ducalex e8d86f7e98
Merge 166e94d82e into faffd15d13 2024-04-24 23:58:42 +00:00
Michael Eischer faffd15d13
Merge pull request #4734 from maouw/enhancement/envvar-for-host
Add support for specifying --host via environment variable
2024-04-24 20:00:15 +00:00
Michael Eischer 347e9d0765 complete RESITC_HOST environment handling & test 2024-04-24 21:52:39 +02:00
Altan Orhon 871ea1eaf3 Add support for specifying --host via environment variable
This commit adds support for specifying the `--host` option via the `RESTIC_HOST` environment variable. This is done by extending option processing in `cmd_backup.go` and for `restic.SnapshotFilter` in `find.go`.
2024-04-24 21:49:42 +02:00
Alex Duchesne 166e94d82e webdav: removed unnecessary error checks 2024-04-07 17:35:11 -04:00
Alex Duchesne 0cb3ef1d92 webdav: added some structure similar to `mount` 2024-04-06 12:10:49 -04:00
Alex Duchesne 7782b7c38e webdav: open Windows Explorer when starting 2024-04-06 10:23:59 -04:00
Alex Duchesne ff7bfc534f Implemented WebDAV server to browse repositories 2024-04-05 23:21:24 -04:00
5 changed files with 559 additions and 3 deletions

View File

@ -0,0 +1,10 @@
Enhancement: Allow specifying `--host` via environment variable
Restic commands that operate on snapshots, such as `restic backup` and
`restic snapshots`, support the `--host` flag to specify the hostname for
grouoping snapshots. They now permit selecting the hostname via the
environment variable `RESTIC_HOST`. `--host` still takes precedence over the
environment variable.
https://github.com/restic/restic/issues/4733
https://github.com/restic/restic/pull/4734

View File

@ -114,7 +114,7 @@ func init() {
f.BoolVar(&backupOptions.StdinCommand, "stdin-from-command", false, "interpret arguments as command to execute and store its stdout")
f.Var(&backupOptions.Tags, "tag", "add `tags` for the new snapshot in the format `tag[,tag,...]` (can be specified multiple times)")
f.UintVar(&backupOptions.ReadConcurrency, "read-concurrency", 0, "read `n` files concurrently (default: $RESTIC_READ_CONCURRENCY or 2)")
f.StringVarP(&backupOptions.Host, "host", "H", "", "set the `hostname` for the snapshot manually. To prevent an expensive rescan use the \"parent\" flag")
f.StringVarP(&backupOptions.Host, "host", "H", "", "set the `hostname` for the snapshot manually (default: $RESTIC_HOST). To prevent an expensive rescan use the \"parent\" flag")
f.StringVar(&backupOptions.Host, "hostname", "", "set the `hostname` for the snapshot manually")
err := f.MarkDeprecated("hostname", "use --host")
if err != nil {
@ -137,6 +137,11 @@ func init() {
// parse read concurrency from env, on error the default value will be used
readConcurrency, _ := strconv.ParseUint(os.Getenv("RESTIC_READ_CONCURRENCY"), 10, 32)
backupOptions.ReadConcurrency = uint(readConcurrency)
// parse host from env, if not exists or empty the default value will be used
if host := os.Getenv("RESTIC_HOST"); host != "" {
backupOptions.Host = host
}
}
// filterExisting returns a slice of all existing items, or an error if no

469
cmd/restic/cmd_webdav.go Normal file
View File

@ -0,0 +1,469 @@
package main
import (
"context"
"io"
"net/http"
"os"
"os/exec"
"path"
"runtime"
"sort"
"strings"
"time"
"github.com/spf13/cobra"
"golang.org/x/net/webdav"
"github.com/restic/restic/internal/bloblru"
"github.com/restic/restic/internal/debug"
"github.com/restic/restic/internal/errors"
"github.com/restic/restic/internal/restic"
"github.com/restic/restic/internal/walker"
)
var cmdWebdav = &cobra.Command{
Use: "webdav [flags] [ip:port]",
Short: "Serve the repository via WebDAV",
Long: `
The "webdav" command serves the repository via WebDAV. This is a
read-only mount.
Snapshot Directories
====================
If you need a different template for directories that contain snapshots,
you can pass a time template via --time-template and path templates via
--path-template.
Example time template without colons:
--time-template "2006-01-02_15-04-05"
You need to specify a sample format for exactly the following timestamp:
Mon Jan 2 15:04:05 -0700 MST 2006
For details please see the documentation for time.Format() at:
https://godoc.org/time#Time.Format
For path templates, you can use the following patterns which will be replaced:
%i by short snapshot ID
%I by long snapshot ID
%u by username
%h by hostname
%t by tags
%T by timestamp as specified by --time-template
The default path templates are:
"ids/%i"
"snapshots/%T"
"hosts/%h/%T"
"tags/%t/%T"
EXIT STATUS
===========
Exit status is 0 if the command was successful, and non-zero if there was any error.
`,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runWebServer(cmd.Context(), webdavOptions, globalOptions, args)
},
}
type WebdavOptions struct {
restic.SnapshotFilter
TimeTemplate string
PathTemplates []string
}
var webdavOptions WebdavOptions
func init() {
cmdRoot.AddCommand(cmdWebdav)
cmdFlags := cmdWebdav.Flags()
initMultiSnapshotFilter(cmdFlags, &webdavOptions.SnapshotFilter, true)
cmdFlags.StringArrayVar(&webdavOptions.PathTemplates, "path-template", nil, "set `template` for path names (can be specified multiple times)")
cmdFlags.StringVar(&webdavOptions.TimeTemplate, "snapshot-template", "2006-01-02_15-04-05", "set `template` to use for snapshot dirs")
}
func runWebServer(ctx context.Context, opts WebdavOptions, gopts GlobalOptions, args []string) error {
if len(args) > 1 {
return errors.Fatal("wrong number of parameters")
}
// FIXME: Proper validation, also add support for IPv6
bindAddress := "127.0.0.1:3080"
if len(args) == 1 {
bindAddress = strings.ToLower(args[0])
}
if strings.Index(bindAddress, "http://") == 0 {
bindAddress = bindAddress[7:]
}
ctx, repo, unlock, err := openWithReadLock(ctx, gopts, gopts.NoLock)
if err != nil {
return err
}
defer unlock()
snapshotLister, err := restic.MemorizeList(ctx, repo, restic.SnapshotFile)
if err != nil {
return err
}
bar := newIndexProgress(gopts.Quiet, gopts.JSON)
err = repo.LoadIndex(ctx, bar)
if err != nil {
return err
}
davFS := &webdavFS{
repo: repo,
root: webdavFSNode{
name: "",
mode: 0555 | os.ModeDir,
modTime: time.Now(),
children: make(map[string]*webdavFSNode),
},
blobCache: bloblru.New(64 << 20),
}
wd := &webdav.Handler{
FileSystem: davFS,
LockSystem: webdav.NewMemLS(),
}
for sn := range FindFilteredSnapshots(ctx, snapshotLister, repo, &webdavOptions.SnapshotFilter, nil) {
node := &webdavFSNode{
name: sn.ID().Str(),
mode: 0555 | os.ModeDir,
modTime: sn.Time,
children: nil,
snapshot: sn,
}
// Ignore PathTemplates for now because `fuse.snapshots_dir(struct)` is not accessible when building
// on Windows and it would be ridiculous to duplicate the code. It should be shared, somehow!
davFS.addNode("/ids/"+node.name, node)
davFS.addNode("/hosts/"+sn.Hostname+"/"+node.name, node)
davFS.addNode("/snapshots/"+sn.Time.Format(opts.TimeTemplate)+"/"+node.name, node)
for _, tag := range sn.Tags {
davFS.addNode("/tags/"+tag+"/"+node.name, node)
}
}
Printf("Now serving the repository at http://%s\n", bindAddress)
Printf("Tree contains %d snapshots\n", len(davFS.root.children))
Printf("When finished, quit with Ctrl-c here.\n")
// FIXME: Remove before PR, this is handy for testing but likely undesirable :)
if runtime.GOOS == "windows" {
browseURL := "\\\\" + strings.Replace(bindAddress, ":", "@", 1) + "\\DavWWWRoot"
exec.Command("explorer", browseURL).Start()
}
return http.ListenAndServe(bindAddress, wd)
}
// Implements webdav.FileSystem
type webdavFS struct {
repo restic.Repository
root webdavFSNode
// snapshots *restic.Snapshot
blobCache *bloblru.Cache
}
// Implements os.FileInfo
type webdavFSNode struct {
name string
mode os.FileMode
modTime time.Time
size int64
children map[string]*webdavFSNode
// Should be an interface to save on memory?
node *restic.Node
snapshot *restic.Snapshot
}
func (f *webdavFSNode) Name() string { return f.name }
func (f *webdavFSNode) Size() int64 { return f.size }
func (f *webdavFSNode) Mode() os.FileMode { return f.mode }
func (f *webdavFSNode) ModTime() time.Time { return f.modTime }
func (f *webdavFSNode) IsDir() bool { return f.mode.IsDir() }
func (f *webdavFSNode) Sys() interface{} { return nil }
func (fs *webdavFS) loadSnapshot(ctx context.Context, mountPoint string, sn *restic.Snapshot) {
Printf("Loading snapshot %s at %s\n", sn.ID().Str(), mountPoint)
// FIXME: Need a mutex here...
// FIXME: All this walking should be done dynamically when the client asks for a folder...
walker.Walk(ctx, fs.repo, *sn.Tree, walker.WalkVisitor{
ProcessNode: func(parentTreeID restic.ID, nodepath string, node *restic.Node, err error) error {
if err != nil || node == nil {
return err
}
fs.addNode(mountPoint+"/"+nodepath, &webdavFSNode{
name: node.Name,
mode: node.Mode,
modTime: node.ModTime,
size: int64(node.Size),
node: node,
// snapshot: sn,
})
return nil
},
})
}
func (fs *webdavFS) addNode(fullpath string, node *webdavFSNode) error {
fullpath = strings.Trim(path.Clean("/"+fullpath), "/")
if fullpath == "" {
return os.ErrInvalid
}
parts := strings.Split(fullpath, "/")
dir := &fs.root
for len(parts) > 0 {
part := parts[0]
parts = parts[1:]
if !dir.IsDir() {
return os.ErrInvalid
}
if dir.children == nil {
dir.children = make(map[string]*webdavFSNode)
}
if len(parts) == 0 {
dir.children[part] = node
dir.size = int64(len(dir.children))
return nil
}
if dir.children[part] == nil {
dir.children[part] = &webdavFSNode{
name: part,
mode: 0555 | os.ModeDir,
modTime: dir.modTime,
children: nil,
}
}
dir = dir.children[part]
}
return os.ErrInvalid
}
func (fs *webdavFS) findNode(fullname string) (*webdavFSNode, error) {
fullname = strings.Trim(path.Clean("/"+fullname), "/")
if fullname == "" {
return &fs.root, nil
}
parts := strings.Split(fullname, "/")
dir := &fs.root
for dir != nil {
node := dir.children[parts[0]]
parts = parts[1:]
if len(parts) == 0 {
return node, nil
}
dir = node
}
return nil, os.ErrNotExist
}
func (fs *webdavFS) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
debug.Log("OpenFile %s", name)
// Client can only read
if flag&(os.O_WRONLY|os.O_RDWR) != 0 {
return nil, os.ErrPermission
}
node, err := fs.findNode(name)
if err == os.ErrNotExist {
// FIXME: Walk up the tree to make sure the snapshot (if any) is loaded
}
if err != nil {
return nil, err
}
return &openFile{fullpath: path.Clean("/" + name), node: node, fs: fs}, nil
}
func (fs *webdavFS) Stat(ctx context.Context, name string) (os.FileInfo, error) {
node, err := fs.findNode(name)
if err != nil {
return nil, err
}
return node, nil
}
func (fs *webdavFS) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
return os.ErrPermission
}
func (fs *webdavFS) RemoveAll(ctx context.Context, name string) error {
return os.ErrPermission
}
func (fs *webdavFS) Rename(ctx context.Context, oldName, newName string) error {
return os.ErrPermission
}
type openFile struct {
fullpath string
node *webdavFSNode
fs *webdavFS
cursor int64
children []os.FileInfo
// cumsize[i] holds the cumulative size of blobs[:i].
cumsize []uint64
initialized bool
}
func (f *openFile) getBlobAt(ctx context.Context, i int) (blob []byte, err error) {
blob, ok := f.fs.blobCache.Get(f.node.node.Content[i])
if ok {
return blob, nil
}
blob, err = f.fs.repo.LoadBlob(ctx, restic.DataBlob, f.node.node.Content[i], nil)
if err != nil {
return nil, err
}
f.fs.blobCache.Add(f.node.node.Content[i], blob)
return blob, nil
}
func (f *openFile) Read(p []byte) (int, error) {
debug.Log("Read %s %d %d", f.fullpath, f.cursor, len(p))
if f.node.IsDir() || f.cursor < 0 {
return 0, os.ErrInvalid
}
if f.cursor >= f.node.Size() {
return 0, io.EOF
}
// We wait until the first read before we do anything because WebDAV clients tend to open
// everything and do nothing...
if !f.initialized {
var bytes uint64
cumsize := make([]uint64, 1+len(f.node.node.Content))
for i, id := range f.node.node.Content {
size, found := f.fs.repo.LookupBlobSize(id, restic.DataBlob)
if !found {
return 0, errors.Errorf("id %v not found in repository", id)
}
bytes += uint64(size)
cumsize[i+1] = bytes
}
if bytes != f.node.node.Size {
Printf("sizes do not match: node.Size %d != size %d", bytes, f.node.Size())
}
f.cumsize = cumsize
f.initialized = true
}
offset := uint64(f.cursor)
remainingBytes := uint64(len(p))
readBytes := 0
if offset+remainingBytes > uint64(f.node.Size()) {
remainingBytes = uint64(f.node.Size()) - remainingBytes
}
// Skip blobs before the offset
startContent := -1 + sort.Search(len(f.cumsize), func(i int) bool {
return f.cumsize[i] > offset
})
offset -= f.cumsize[startContent]
for i := startContent; remainingBytes > 0 && i < len(f.cumsize)-1; i++ {
blob, err := f.getBlobAt(context.TODO(), i)
if err != nil {
return 0, err
}
if offset > 0 {
blob = blob[offset:]
offset = 0
}
copied := copy(p, blob)
remainingBytes -= uint64(copied)
readBytes += copied
p = p[copied:]
}
f.cursor += int64(readBytes)
return readBytes, nil
}
func (f *openFile) Readdir(count int) ([]os.FileInfo, error) {
debug.Log("Readdir %s %d %d", f.fullpath, f.cursor, count)
if !f.node.IsDir() || f.cursor < 0 {
return nil, os.ErrInvalid
}
// We wait until the first read before we do anything because WebDAV clients tend to open
// everything and do nothing...
if !f.initialized {
// It's a snapshot, mount it
if f.node.snapshot != nil && f.node.children == nil {
f.fs.loadSnapshot(context.TODO(), f.fullpath, f.node.snapshot)
}
children := make([]os.FileInfo, 0, len(f.node.children))
for _, c := range f.node.children {
children = append(children, c)
}
f.children = children
f.initialized = true
}
if count <= 0 {
return f.children, nil
}
if f.cursor >= f.node.Size() {
return nil, io.EOF
}
start := f.cursor
f.cursor += int64(count)
if f.cursor > f.node.Size() {
f.cursor = f.node.Size()
}
return f.children[start:f.cursor], nil
}
func (f *openFile) Seek(offset int64, whence int) (int64, error) {
debug.Log("Seek %s %d %d", f.fullpath, offset, whence)
switch whence {
case io.SeekStart:
f.cursor = offset
case io.SeekCurrent:
f.cursor += offset
case io.SeekEnd:
f.cursor = f.node.Size() - offset
default:
return 0, os.ErrInvalid
}
return f.cursor, nil
}
func (f *openFile) Stat() (os.FileInfo, error) {
return f.node, nil
}
func (f *openFile) Write(p []byte) (int, error) {
return 0, os.ErrPermission
}
func (f *openFile) Close() error {
return nil
}

View File

@ -2,6 +2,7 @@ package main
import (
"context"
"os"
"github.com/restic/restic/internal/restic"
"github.com/spf13/pflag"
@ -14,17 +15,27 @@ func initMultiSnapshotFilter(flags *pflag.FlagSet, filt *restic.SnapshotFilter,
if !addHostShorthand {
hostShorthand = ""
}
flags.StringArrayVarP(&filt.Hosts, "host", hostShorthand, nil, "only consider snapshots for this `host` (can be specified multiple times)")
flags.StringArrayVarP(&filt.Hosts, "host", hostShorthand, nil, "only consider snapshots for this `host` (can be specified multiple times) (default: $RESTIC_HOST)")
flags.Var(&filt.Tags, "tag", "only consider snapshots including `tag[,tag,...]` (can be specified multiple times)")
flags.StringArrayVar(&filt.Paths, "path", nil, "only consider snapshots including this (absolute) `path` (can be specified multiple times)")
// set default based on env if set
if host := os.Getenv("RESTIC_HOST"); host != "" {
filt.Hosts = []string{host}
}
}
// initSingleSnapshotFilter is used for commands that work on a single snapshot
// MUST be combined with restic.FindFilteredSnapshot
func initSingleSnapshotFilter(flags *pflag.FlagSet, filt *restic.SnapshotFilter) {
flags.StringArrayVarP(&filt.Hosts, "host", "H", nil, "only consider snapshots for this `host`, when snapshot ID \"latest\" is given (can be specified multiple times)")
flags.StringArrayVarP(&filt.Hosts, "host", "H", nil, "only consider snapshots for this `host`, when snapshot ID \"latest\" is given (can be specified multiple times) (default: $RESTIC_HOST)")
flags.Var(&filt.Tags, "tag", "only consider snapshots including `tag[,tag,...]`, when snapshot ID \"latest\" is given (can be specified multiple times)")
flags.StringArrayVar(&filt.Paths, "path", nil, "only consider snapshots including this (absolute) `path`, when snapshot ID \"latest\" is given (can be specified multiple times)")
// set default based on env if set
if host := os.Getenv("RESTIC_HOST"); host != "" {
filt.Hosts = []string{host}
}
}
// FindFilteredSnapshots yields Snapshots, either given explicitly by `snapshotIDs` or filtered from the list of all snapshots.

61
cmd/restic/find_test.go Normal file
View File

@ -0,0 +1,61 @@
package main
import (
"testing"
"github.com/restic/restic/internal/restic"
rtest "github.com/restic/restic/internal/test"
"github.com/spf13/pflag"
)
func TestSnapshotFilter(t *testing.T) {
for _, test := range []struct {
name string
args []string
expected []string
env string
}{
{
"no value",
[]string{},
nil,
"",
},
{
"args only",
[]string{"--host", "abc"},
[]string{"abc"},
"",
},
{
"env default",
[]string{},
[]string{"def"},
"def",
},
{
"both",
[]string{"--host", "abc"},
[]string{"abc"},
"def",
},
} {
t.Run(test.name, func(t *testing.T) {
t.Setenv("RESTIC_HOST", test.env)
for _, mode := range []bool{false, true} {
set := pflag.NewFlagSet("test", pflag.PanicOnError)
flt := &restic.SnapshotFilter{}
if mode {
initMultiSnapshotFilter(set, flt, false)
} else {
initSingleSnapshotFilter(set, flt)
}
err := set.Parse(test.args)
rtest.OK(t, err)
rtest.Equals(t, test.expected, flt.Hosts, "unexpected hosts")
}
})
}
}