Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add new storage backend: Dropbox (#103) #251

Merged
merged 49 commits into from
Aug 24, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
4e04cb9
Add new storage backend: Dropbox (#103)
MaxJa4 Aug 20, 2023
6362b32
Remove duplicate check
MaxJa4 Aug 21, 2023
05c7a15
Add concurrency level for parallel upload to dropbox.
MaxJa4 Aug 21, 2023
f29b4c0
Fixed some instabilites. Changed default concurrency to 6.
MaxJa4 Aug 21, 2023
c5d596d
Added some env config vars to readme. WIP
MaxJa4 Aug 21, 2023
ddca5c4
Wrap errors for storage backend creation.
MaxJa4 Aug 21, 2023
70275ea
Fixed token issue, added OAuth2 including recipe and docs.
MaxJa4 Aug 21, 2023
894608f
Readme typo fix
MaxJa4 Aug 21, 2023
cd49b8c
Test for dropbox integration
MaxJa4 Aug 22, 2023
2ea59a6
Update info and TOC
MaxJa4 Aug 22, 2023
c7b9ef7
Missed a file
MaxJa4 Aug 22, 2023
f60f633
Docker-compose fix
MaxJa4 Aug 22, 2023
5f6b547
Fix endpoint connection
MaxJa4 Aug 22, 2023
2db707c
Fix container names
MaxJa4 Aug 22, 2023
a64c6ab
Fix log fetching
MaxJa4 Aug 22, 2023
385d692
Fix log fetching (again)
MaxJa4 Aug 22, 2023
08ae3c7
Print command output to logs
MaxJa4 Aug 22, 2023
c304b4f
Addressing comments part 1
MaxJa4 Aug 23, 2023
5147753
Address comments part 2
MaxJa4 Aug 23, 2023
7ddeb99
Add OAuth2 mock server for CI testing
MaxJa4 Aug 23, 2023
0e46fc3
Fix env name of oauth2 endpoint
MaxJa4 Aug 23, 2023
a5ce440
Remove hostname
MaxJa4 Aug 23, 2023
955f98e
Add forgotten change to commit...
MaxJa4 Aug 23, 2023
396a75d
Fix oauth2 endpoint
MaxJa4 Aug 23, 2023
155f0e4
Try again
MaxJa4 Aug 23, 2023
4f7fb2c
Try suggested hostname again
MaxJa4 Aug 23, 2023
1b5e449
Fix docker internal DNS resolving issues (as suggested by oauth2 mock…
MaxJa4 Aug 23, 2023
84fc0c2
Add docker network, remove hostname
MaxJa4 Aug 23, 2023
f539240
Network not external
MaxJa4 Aug 23, 2023
569f74e
Last hostname try
MaxJa4 Aug 23, 2023
4d7af32
Add more delay, add oauth2 endpoint log
MaxJa4 Aug 23, 2023
4c4c431
Temp CI log output of command even when failing
MaxJa4 Aug 23, 2023
fe2ffa0
Try different config and method
MaxJa4 Aug 23, 2023
30159de
Add custom server-hostname. Rename test folder to accellerate debugging
MaxJa4 Aug 23, 2023
4dbfc76
Try that fix again
MaxJa4 Aug 23, 2023
bf73fa0
Adding quotes
MaxJa4 Aug 23, 2023
8b626c3
Port fix attempt
MaxJa4 Aug 23, 2023
3e32fe5
Try localhost
MaxJa4 Aug 23, 2023
1ba97e9
Try extra hosts
MaxJa4 Aug 23, 2023
58758dd
Change network mode
MaxJa4 Aug 23, 2023
cb0f79e
Undo some changes
MaxJa4 Aug 23, 2023
159a9d5
Use static IP
MaxJa4 Aug 23, 2023
4d415bc
Remove specific IP binding
MaxJa4 Aug 23, 2023
8654234
Change to default net driver
MaxJa4 Aug 23, 2023
32462de
Fix static IP
MaxJa4 Aug 23, 2023
e9b617b
Squash for revert
MaxJa4 Aug 23, 2023
b2b9e3a
Merge branch 'dropbox-storage-backend' of https://github.com/MaxJa4/d…
MaxJa4 Aug 23, 2023
3899afb
Revert "Squash for revert"
MaxJa4 Aug 23, 2023
1da61c5
Actual fix for CI testing from #257
MaxJa4 Aug 24, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/backup/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ type Config struct {
AzureStorageContainerName string `split_words:"true"`
AzureStoragePath string `split_words:"true"`
AzureStorageEndpoint string `split_words:"true" default:"https://{{ .AccountName }}.blob.core.windows.net/"`
DropboxToken string `split_words:"true"`
DropboxRemotePath string `split_words:"true"`
}

func (c *Config) resolveSecret(envVar string, secretPath string) (string, error) {
Expand Down
24 changes: 19 additions & 5 deletions cmd/backup/script.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (

"github.com/offen/docker-volume-backup/internal/storage"
"github.com/offen/docker-volume-backup/internal/storage/azure"
"github.com/offen/docker-volume-backup/internal/storage/dropbox"
"github.com/offen/docker-volume-backup/internal/storage/local"
"github.com/offen/docker-volume-backup/internal/storage/s3"
"github.com/offen/docker-volume-backup/internal/storage/ssh"
Expand Down Expand Up @@ -70,11 +71,12 @@ func newScript() (*script, error) {
StartTime: time.Now(),
LogOutput: logBuffer,
Storages: map[string]StorageStats{
"S3": {},
"WebDAV": {},
"SSH": {},
"Local": {},
"Azure": {},
"S3": {},
"WebDAV": {},
"SSH": {},
"Local": {},
"Azure": {},
"Dropbox": {},
},
},
}
Expand Down Expand Up @@ -218,6 +220,18 @@ func newScript() (*script, error) {
s.storages = append(s.storages, azureBackend)
}

if s.c.DropboxToken != "" {
dropboxConfig := dropbox.Config{
Token: s.c.DropboxToken,
RemotePath: s.c.DropboxRemotePath,
}
dropboxBackend, err := dropbox.NewStorageBackend(dropboxConfig, logFunc)
if err != nil {
return nil, err
MaxJa4 marked this conversation as resolved.
Show resolved Hide resolved
}
s.storages = append(s.storages, dropboxBackend)
}

if s.c.EmailNotificationRecipient != "" {
emailURL := fmt.Sprintf(
"smtp://%s:%s@%s:%d/?from=%s&to=%s",
Expand Down
8 changes: 8 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ require (
golang.org/x/sync v0.3.0
)

require (
github.com/golang/protobuf v1.5.2 // indirect
golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.28.1 // indirect
)

require (
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.6.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0 // indirect
Expand All @@ -28,6 +35,7 @@ require (
github.com/docker/distribution v2.8.2+incompatible // indirect
github.com/docker/go-connections v0.4.0 // indirect
github.com/docker/go-units v0.4.0 // indirect
github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.13.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,8 @@ github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKoh
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw=
github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5 h1:FT+t0UEDykcor4y3dMVKXIiWJETBpRgERYTGlmMd7HU=
github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5/go.mod h1:rSS3kM9XMzSQ6pw91Qgd6yB5jdt70N4OdtrAf74As5M=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
Expand Down Expand Up @@ -785,6 +787,7 @@ golang.org/x/oauth2 v0.0.0-20220608161450-d0670ef3b1eb/go.mod h1:jaDAt6Dkxork7Lm
golang.org/x/oauth2 v0.0.0-20220622183110-fd043fe589d2/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE=
golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg=
golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg=
golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783 h1:nt+Q6cXKz4MosCSpnbMtqiQ8Oz0pxTef2B4Vca2lvfk=
golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
Expand Down Expand Up @@ -1046,6 +1049,7 @@ google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
Expand Down
182 changes: 182 additions & 0 deletions internal/storage/dropbox/dropbox.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package dropbox

import (
"bytes"
"errors"
"fmt"
"os"
"path"
"path/filepath"
"reflect"
"strings"
"time"

"github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox"
"github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files"
"github.com/offen/docker-volume-backup/internal/storage"
)

type dropboxStorage struct {
*storage.StorageBackend
client files.Client
}

// Config allows to configure a Dropbox storage backend.
type Config struct {
Token string
RemotePath string
}

// NewStorageBackend creates and initializes a new Dropbox storage backend.
func NewStorageBackend(opts Config, logFunc storage.Log) (storage.Backend, error) {
if opts.Token == "" {
MaxJa4 marked this conversation as resolved.
Show resolved Hide resolved
return nil, errors.New("NewStorageBackend: No Dropbox token has been provided")
} else {
config := dropbox.Config{
Token: opts.Token,
}

client := files.New(config)

return &dropboxStorage{
StorageBackend: &storage.StorageBackend{
DestinationPath: opts.RemotePath,
Log: logFunc,
},
client: client,
}, nil
}
}

// Name returns the name of the storage backend
func (b *dropboxStorage) Name() string {
return "Dropbox"
}

// Copy copies the given file to the WebDav storage backend.
func (b *dropboxStorage) Copy(file string) error {
_, name := path.Split(file)

folderArg := files.NewCreateFolderArg(b.DestinationPath)
if _, err := b.client.CreateFolderV2(folderArg); err != nil {
if err.(files.CreateFolderV2APIError).EndpointError.Path.Tag == files.WriteErrorConflict {
b.Log(storage.LogLevelInfo, b.Name(), "Destination path '%s' already exists in Dropbox, no new directory required.", b.DestinationPath)
} else {
return fmt.Errorf("(*dropboxStorage).Copy: Error creating directory '%s' in Dropbox: %w", b.DestinationPath, err)
}
}

r, err := os.Open(file)
if err != nil {
return fmt.Errorf("(*dropboxStorage).Copy: Error opening the file to be uploaded: %w", err)
}
defer r.Close()

// Start new upload session and get session id

b.Log(storage.LogLevelInfo, b.Name(), "Starting upload session for backup '%s' to Dropbox at path '%s'.", file, b.DestinationPath)

var sessionId string
uploadSessionStartArg := files.NewUploadSessionStartArg()
uploadSessionStartArg.SessionType = &files.UploadSessionType{Tagged: dropbox.Tagged{Tag: files.UploadSessionTypeConcurrent}}
if res, err := b.client.UploadSessionStart(uploadSessionStartArg, nil); err != nil {
return fmt.Errorf("(*dropboxStorage).Copy: Error starting the upload session: %w", err)
} else {
sessionId = res.SessionId
}

// Send the file in 148MB chunks (Dropbox API limit is 150MB, concurrent upload requires a multiple of 4MB though)
// Last append can be any size <= 150MB with Close=True

const chunkSize = 148 * 1024 * 1024 // 148MB
var offset uint64 = 0

for {
MaxJa4 marked this conversation as resolved.
Show resolved Hide resolved
chunk := make([]byte, chunkSize)
bytesRead, err := r.Read(chunk)
if err != nil {
return fmt.Errorf("(*dropboxStorage).Copy: Error reading the file to be uploaded: %w", err)
}
chunk = chunk[:bytesRead]

uploadSessionAppendArg := files.NewUploadSessionAppendArg(
files.NewUploadSessionCursor(sessionId, offset),
)
isEOF := bytesRead < chunkSize
uploadSessionAppendArg.Close = isEOF

if err := b.client.UploadSessionAppendV2(uploadSessionAppendArg, bytes.NewReader(chunk)); err != nil {
return fmt.Errorf("(*dropboxStorage).Copy: Error appending the file to the upload session: %w", err)
}

if isEOF {
break
}

offset += uint64(bytesRead)
}

// Finish the upload session, commit the file (no new data added)

b.client.UploadSessionFinish(
files.NewUploadSessionFinishArg(
files.NewUploadSessionCursor(sessionId, 0),
files.NewCommitInfo(filepath.Join(b.DestinationPath, name)),
), nil)

b.Log(storage.LogLevelInfo, b.Name(), "Uploaded a copy of backup '%s' to Dropbox at path '%s'.", file, b.DestinationPath)

return nil
}

// Prune rotates away backups according to the configuration and provided deadline for the Dropbox storage backend.
func (b *dropboxStorage) Prune(deadline time.Time, pruningPrefix string) (*storage.PruneStats, error) {
var entries []files.IsMetadata
res, err := b.client.ListFolder(files.NewListFolderArg(b.DestinationPath))
if err != nil {
return nil, fmt.Errorf("(*webDavStorage).Prune: Error looking up candidates from remote storage: %w", err)
}
entries = append(entries, res.Entries...)

for res.HasMore {
res, err = b.client.ListFolderContinue(files.NewListFolderContinueArg(res.Cursor))
if err != nil {
return nil, fmt.Errorf("(*webDavStorage).Prune: Error looking up candidates from remote storage: %w", err)
}
entries = append(entries, res.Entries...)
}

var matches []*files.FileMetadata
var lenCandidates int
for _, candidate := range entries {
if reflect.Indirect(reflect.ValueOf(candidate)).Type() != reflect.TypeOf(files.FileMetadata{}) {
MaxJa4 marked this conversation as resolved.
Show resolved Hide resolved
continue
}
candidate := candidate.(*files.FileMetadata)
if !strings.HasPrefix(candidate.Name, pruningPrefix) {
continue
}
lenCandidates++
if candidate.ServerModified.Before(deadline) {
matches = append(matches, candidate)
}
}

stats := &storage.PruneStats{
Total: uint(lenCandidates),
Pruned: uint(len(matches)),
}

if err := b.DoPrune(b.Name(), len(matches), lenCandidates, "Dropbox backup(s)", func() error {
for _, match := range matches {
if _, err := b.client.DeleteV2(files.NewDeleteArg(filepath.Join(b.DestinationPath, match.Name))); err != nil {
return fmt.Errorf("(*dropboxStorage).Prune: Error removing file from Dropbox storage: %w", err)
}
}
return nil
}); err != nil {
return stats, err
}

return stats, nil
}