mirror of
https://github.com/offen/docker-volume-backup.git
synced 2025-12-05 17:18:02 +01:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cb2ae1893 | ||
|
|
95bf34280d | ||
|
|
186953f666 | ||
|
|
16b3a7170a |
@@ -177,10 +177,20 @@ func (s *script) init() error {
|
||||
IdentityPassphrase: s.c.SSHIdentityPassphrase,
|
||||
RemotePath: s.c.SSHRemotePath,
|
||||
}
|
||||
sshBackend, err := ssh.NewStorageBackend(sshConfig, logFunc)
|
||||
|
||||
sshBackend, closeSSHConnection, err := ssh.NewStorageBackend(sshConfig, logFunc)
|
||||
|
||||
s.registerHook(hookLevelPlumbing, func(err error) error {
|
||||
if err := closeSSHConnection(); err != nil {
|
||||
return errwrap.Wrap(err, "failed to close ssh connection")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return errwrap.Wrap(err, "error creating ssh storage backend")
|
||||
}
|
||||
|
||||
s.storages = append(s.storages, sshBackend)
|
||||
}
|
||||
|
||||
|
||||
6
go.mod
6
go.mod
@@ -14,7 +14,7 @@ require (
|
||||
github.com/klauspost/compress v1.18.1
|
||||
github.com/leekchan/timeutil v0.0.0-20150802142658-28917288c48d
|
||||
github.com/minio/minio-go/v7 v7.0.95
|
||||
github.com/nicholas-fedor/shoutrrr v0.11.0
|
||||
github.com/nicholas-fedor/shoutrrr v0.11.1
|
||||
github.com/offen/envconfig v1.5.0
|
||||
github.com/otiai10/copy v1.14.1
|
||||
github.com/pkg/sftp v1.13.10
|
||||
@@ -23,7 +23,7 @@ require (
|
||||
golang.org/x/crypto v0.43.0
|
||||
golang.org/x/oauth2 v0.32.0
|
||||
golang.org/x/sync v0.17.0
|
||||
google.golang.org/api v0.253.0
|
||||
google.golang.org/api v0.254.0
|
||||
mvdan.cc/sh/v3 v3.12.0
|
||||
)
|
||||
|
||||
@@ -72,7 +72,7 @@ require (
|
||||
go.opentelemetry.io/proto/otlp v1.7.0 // indirect
|
||||
golang.org/x/term v0.36.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect
|
||||
google.golang.org/grpc v1.76.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
)
|
||||
|
||||
18
go.sum
18
go.sum
@@ -304,16 +304,16 @@ github.com/moby/term v0.0.0-20200312100748-672ec06f55cd h1:aY7OQNf2XqY/JQ6qREWam
|
||||
github.com/moby/term v0.0.0-20200312100748-672ec06f55cd/go.mod h1:DdlQx2hp0Ss5/fLikoLlEeIYiATotOjgB//nb973jeo=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/nicholas-fedor/shoutrrr v0.11.0 h1:hAMv2uM8OfFXkMHVP977elkP3Wgw5/YpVX5GxXQwiWA=
|
||||
github.com/nicholas-fedor/shoutrrr v0.11.0/go.mod h1:0kRF9ral22xUn/0BlxfhLQUeJDTySCPsuNvaclyagb4=
|
||||
github.com/nicholas-fedor/shoutrrr v0.11.1 h1:DND1gW8UM8GYG8c0bUZ5fPFAnm3id8noPdfaFBUmezk=
|
||||
github.com/nicholas-fedor/shoutrrr v0.11.1/go.mod h1:RZuSZSEaSimS47zTOLXb6HJDwLjDHiuJ9SrzxsDcWaQ=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/offen/envconfig v1.5.0 h1:LHL4wYIDVeoGxSDI40MShmWfss3gYUlCdstfSiSq4Fk=
|
||||
github.com/offen/envconfig v1.5.0/go.mod h1:L7ny7R+4JWH3VVnZ+ARHvZysWUiZ2eQcm3L0imU9ACY=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.12.0 h1:Iw5WCbBcaAAd0fpRb1c9r5YCylv4XDoCSigm1zLevwU=
|
||||
github.com/onsi/ginkgo v1.12.0/go.mod h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg=
|
||||
github.com/onsi/ginkgo/v2 v2.27.1 h1:0LJC8MpUSQnfnp4n/3W3GdlmJP3ENGF0ZPzjQGLPP7s=
|
||||
github.com/onsi/ginkgo/v2 v2.27.1/go.mod h1:wmy3vCqiBjirARfVhAqFpYt8uvX0yaFe+GudAqqcCqA=
|
||||
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
|
||||
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA=
|
||||
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
||||
@@ -423,8 +423,6 @@ go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mx
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os=
|
||||
go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo=
|
||||
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
@@ -637,8 +635,8 @@ google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0M
|
||||
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
|
||||
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
|
||||
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
|
||||
google.golang.org/api v0.253.0 h1:apU86Eq9Q2eQco3NsUYFpVTfy7DwemojL7LmbAj7g/I=
|
||||
google.golang.org/api v0.253.0/go.mod h1:PX09ad0r/4du83vZVAaGg7OaeyGnaUmT/CYPNvtLCbw=
|
||||
google.golang.org/api v0.254.0 h1:jl3XrGj7lRjnlUvZAbAdhINTLbsg5dbjmR90+pTQvt4=
|
||||
google.golang.org/api v0.254.0/go.mod h1:5BkSURm3D9kAqjGvBNgf0EcbX6Rnrf6UArKkwBzAyqQ=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
@@ -678,8 +676,8 @@ google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuO
|
||||
google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b h1:ULiyYQ0FdsJhwwZUwbaXpZF5yUE3h+RA+gxvBu37ucc=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:oDOGiMSXHL4sDTJvFvIB9nRQCGdLP1o/iVaqQK8zB+M=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f h1:1FTH6cpXFsENbPR5Bu8NQddPSaUUE6NA2XdZdDSAJK4=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.0.5/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
|
||||
@@ -36,8 +36,10 @@ type Config struct {
|
||||
RemotePath string
|
||||
}
|
||||
|
||||
var noop = func() error { return nil }
|
||||
|
||||
// NewStorageBackend creates and initializes a new SSH storage backend.
|
||||
func NewStorageBackend(opts Config, logFunc storage.Log) (storage.Backend, error) {
|
||||
func NewStorageBackend(opts Config, logFunc storage.Log) (storage.Backend, func() error, error) {
|
||||
var authMethods []ssh.AuthMethod
|
||||
|
||||
if opts.Password != "" {
|
||||
@@ -47,20 +49,20 @@ func NewStorageBackend(opts Config, logFunc storage.Log) (storage.Backend, error
|
||||
if _, err := os.Stat(opts.IdentityFile); err == nil {
|
||||
key, err := os.ReadFile(opts.IdentityFile)
|
||||
if err != nil {
|
||||
return nil, errwrap.Wrap(nil, "error reading the private key")
|
||||
return nil, noop, errwrap.Wrap(nil, "error reading the private key")
|
||||
}
|
||||
|
||||
var signer ssh.Signer
|
||||
if opts.IdentityPassphrase != "" {
|
||||
signer, err = ssh.ParsePrivateKeyWithPassphrase(key, []byte(opts.IdentityPassphrase))
|
||||
if err != nil {
|
||||
return nil, errwrap.Wrap(nil, "error parsing the encrypted private key")
|
||||
return nil, noop, errwrap.Wrap(nil, "error parsing the encrypted private key")
|
||||
}
|
||||
authMethods = append(authMethods, ssh.PublicKeys(signer))
|
||||
} else {
|
||||
signer, err = ssh.ParsePrivateKey(key)
|
||||
if err != nil {
|
||||
return nil, errwrap.Wrap(nil, "error parsing the private key")
|
||||
return nil, noop, errwrap.Wrap(nil, "error parsing the private key")
|
||||
}
|
||||
authMethods = append(authMethods, ssh.PublicKeys(signer))
|
||||
}
|
||||
@@ -72,13 +74,12 @@ func NewStorageBackend(opts Config, logFunc storage.Log) (storage.Backend, error
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
}
|
||||
sshClient, err := ssh.Dial("tcp", fmt.Sprintf("%s:%s", opts.HostName, opts.Port), sshClientConfig)
|
||||
|
||||
if err != nil {
|
||||
return nil, errwrap.Wrap(err, "error creating ssh client")
|
||||
if err != nil || sshClient == nil {
|
||||
return nil, noop, errwrap.Wrap(err, "error creating ssh client")
|
||||
}
|
||||
_, _, err = sshClient.SendRequest("keepalive", false, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, sshClient.Close, err
|
||||
}
|
||||
|
||||
sftpClient, err := sftp.NewClient(sshClient,
|
||||
@@ -87,7 +88,7 @@ func NewStorageBackend(opts Config, logFunc storage.Log) (storage.Backend, error
|
||||
sftp.MaxConcurrentRequestsPerFile(64),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errwrap.Wrap(err, "error creating sftp client")
|
||||
return nil, sshClient.Close, errwrap.Wrap(err, "error creating sftp client")
|
||||
}
|
||||
|
||||
return &sshStorage{
|
||||
@@ -98,7 +99,7 @@ func NewStorageBackend(opts Config, logFunc storage.Log) (storage.Backend, error
|
||||
client: sshClient,
|
||||
sftpClient: sftpClient,
|
||||
hostName: opts.HostName,
|
||||
}, nil
|
||||
}, sshClient.Close, nil
|
||||
}
|
||||
|
||||
// Name returns the name of the storage backend
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
services:
|
||||
backup:
|
||||
image: offen/docker-volume-backup:${TEST_VERSION:-canary}
|
||||
hostname: hostnametoken
|
||||
restart: always
|
||||
environment:
|
||||
BACKUP_FILENAME_EXPAND: 'true'
|
||||
BACKUP_FILENAME: test-$$HOSTNAME.tar.gz
|
||||
BACKUP_LATEST_SYMLINK: test-$$HOSTNAME.latest.tar.gz.gpg
|
||||
BACKUP_FILENAME: test.tar.gz
|
||||
BACKUP_CRON_EXPRESSION: 0 0 5 31 2 ?
|
||||
BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-7}
|
||||
BACKUP_PRUNING_LEEWAY: 5s
|
||||
BACKUP_PRUNING_PREFIX: test
|
||||
volumes:
|
||||
- app_data:/backup/app_data:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
|
||||
@@ -11,9 +11,6 @@ export LOCAL_DIR=$(mktemp -d)
|
||||
docker compose up -d --quiet-pull
|
||||
sleep 5
|
||||
|
||||
# A symlink for a known file in the volume is created so the test can check
|
||||
# whether symlinks are preserved on backup.
|
||||
docker compose exec offen ln -s /var/opt/offen/offen.db /var/opt/offen/db.link
|
||||
docker compose exec backup backup
|
||||
|
||||
sleep 5
|
||||
@@ -21,56 +18,7 @@ sleep 5
|
||||
expect_running_containers "1"
|
||||
|
||||
tmp_dir=$(mktemp -d)
|
||||
tar -xvf "$LOCAL_DIR/test-hostnametoken.tar.gz" -C $tmp_dir
|
||||
tar -xvf "$LOCAL_DIR/test.tar.gz" -C $tmp_dir
|
||||
if [ ! -f "$tmp_dir/backup/app_data/offen.db" ]; then
|
||||
fail "Could not find expected file in untared archive."
|
||||
fi
|
||||
rm -f "$LOCAL_DIR/test-hostnametoken.tar.gz"
|
||||
|
||||
if [ ! -L "$tmp_dir/backup/app_data/db.link" ]; then
|
||||
fail "Could not find expected symlink in untared archive."
|
||||
fi
|
||||
|
||||
pass "Found relevant files in decrypted and untared local backup."
|
||||
|
||||
if [ ! -L "$LOCAL_DIR/test-hostnametoken.latest.tar.gz.gpg" ]; then
|
||||
fail "Could not find symlink to latest version."
|
||||
fi
|
||||
|
||||
pass "Found symlink to latest version in local backup."
|
||||
|
||||
# The second part of this test checks if backups get deleted when the retention
|
||||
# is set to 0 days (which it should not as it would mean all backups get deleted)
|
||||
BACKUP_RETENTION_DAYS="0" docker compose up -d
|
||||
sleep 5
|
||||
|
||||
docker compose exec backup backup
|
||||
|
||||
if [ "$(find "$LOCAL_DIR" -type f | wc -l)" != "1" ]; then
|
||||
fail "Backups should not have been deleted, instead seen: "$(find "$local_dir" -type f)""
|
||||
fi
|
||||
pass "Local backups have not been deleted."
|
||||
|
||||
# The third part of this test checks if old backups get deleted when the retention
|
||||
# is set to 7 days (which it should)
|
||||
|
||||
BACKUP_RETENTION_DAYS="7" docker compose up -d
|
||||
sleep 5
|
||||
|
||||
info "Create first backup with no prune"
|
||||
docker compose exec backup backup
|
||||
|
||||
touch -r "$LOCAL_DIR/test-hostnametoken.tar.gz" -d "14 days ago" "$LOCAL_DIR/test-hostnametoken-old.tar.gz"
|
||||
|
||||
info "Create second backup and prune"
|
||||
docker compose exec backup backup
|
||||
|
||||
if [ -f "$LOCAL_DIR/test-hostnametoken-old.tar.gz" ]; then
|
||||
fail "Backdated file has not been deleted."
|
||||
fi
|
||||
|
||||
if [ ! -f "$LOCAL_DIR/test-hostnametoken.tar.gz" ]; then
|
||||
fail "Recent file has been deleted."
|
||||
fi
|
||||
|
||||
pass "Old remote backup has been pruned, new one is still present."
|
||||
|
||||
Reference in New Issue
Block a user