mirror of
https://github.com/offen/docker-volume-backup.git
synced 2025-12-05 17:18:02 +01:00
Compare commits
33 Commits
v2.36.0-al
...
v2.38.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8307a2b5b | ||
|
|
060a6daa7a | ||
|
|
4b3ca2ebb0 | ||
|
|
02ba9939a2 | ||
|
|
911fc5a223 | ||
|
|
f64aaa6e24 | ||
|
|
dd8ff5ee0c | ||
|
|
52c22a1891 | ||
|
|
83fa0aae48 | ||
|
|
c4e480dcfd | ||
|
|
a01fc3df3f | ||
|
|
37f9bd9a8f | ||
|
|
fb4663b087 | ||
|
|
0fe983dfcc | ||
|
|
5c8bc107de | ||
|
|
9a1e885138 | ||
|
|
241b5d2f25 | ||
|
|
aab47509d9 | ||
|
|
9b52c1f63e | ||
|
|
164d6df3b4 | ||
|
|
4c74313222 | ||
|
|
de03d4f704 | ||
|
|
65626dd3d4 | ||
|
|
69eceb3982 | ||
|
|
1d45062100 | ||
|
|
64d934102d | ||
|
|
0f224e4fb8 | ||
|
|
6029225f74 | ||
|
|
63b545787e | ||
|
|
c3daeacecb | ||
|
|
2065fb2815 | ||
|
|
97e5aa42cc | ||
|
|
ed5abd5ba8 |
5
.github/workflows/deploy-docs.yml
vendored
5
.github/workflows/deploy-docs.yml
vendored
@@ -3,6 +3,9 @@ name: Deploy Documenation site to GitHub Pages
|
||||
on:
|
||||
push:
|
||||
branches: ['main']
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- '.github/workflows/deploy-docs.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -19,7 +22,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
|
||||
6
.github/workflows/golangci-lint.yml
vendored
6
.github/workflows/golangci-lint.yml
vendored
@@ -15,10 +15,10 @@ jobs:
|
||||
name: lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.21'
|
||||
go-version: '1.22'
|
||||
cache: false
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v3
|
||||
|
||||
41
.github/workflows/release.yml
vendored
41
.github/workflows/release.yml
vendored
@@ -13,7 +13,39 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: set Environment Variables
|
||||
id: env
|
||||
run: |
|
||||
echo "NOW=$(date +'%F %Z %T')" >> $GITHUB_ENV
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
offen/docker-volume-backup
|
||||
ghcr.io/offen/docker-volume-backup
|
||||
# define global behaviour for tags
|
||||
flavor: |
|
||||
latest=false
|
||||
# specify one tag which never gets set, to prevent the tag-attribute being empty, as it will fallback to a default
|
||||
tags: |
|
||||
# output v2.42.1-alpha.1 (incl. pre-releases)
|
||||
type=semver,pattern=v{{version}},enable=false
|
||||
labels: |
|
||||
org.opencontainers.image.title=${{github.event.repository.name}}
|
||||
org.opencontainers.image.description=Backup Docker volumes locally or to any S3, WebDAV, Azure Blob Storage, Dropbox or SSH compatible storage
|
||||
org.opencontainers.image.vendor=${{github.repository_owner}}
|
||||
org.opencontainers.image.licenses=MPL-2.0
|
||||
org.opencontainers.image.version=${{github.ref_name}}
|
||||
org.opencontainers.image.created=${{ env.NOW }}
|
||||
org.opencontainers.image.source=${{github.server_url}}/${{github.repository}}
|
||||
org.opencontainers.image.revision=${{github.sha}}
|
||||
org.opencontainers.image.url=https://offen.github.io/docker-volume-backup/
|
||||
org.opencontainers.image.documentation=https://offen.github.io/docker-volume-backup/
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
@@ -35,7 +67,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker tags
|
||||
id: meta
|
||||
id: tags
|
||||
run: |
|
||||
version_tag="${{github.ref_name}}"
|
||||
tags=($version_tag)
|
||||
@@ -51,9 +83,10 @@ jobs:
|
||||
echo "releases=$releases" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: ${{ steps.meta.outputs.releases }}
|
||||
tags: ${{ steps.tags.outputs.releases }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
21
.github/workflows/unit.yml
vendored
Normal file
21
.github/workflows/unit.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
name: Run Unit Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '1.22.x'
|
||||
- name: Install dependencies
|
||||
run: go mod download
|
||||
- name: Test with the Go CLI
|
||||
run: go test -v ./...
|
||||
@@ -1,7 +1,7 @@
|
||||
# Copyright 2021 - Offen Authors <hioffen@posteo.de>
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
FROM golang:1.21-alpine as builder
|
||||
FROM golang:1.22-alpine as builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
@@ -13,9 +13,9 @@ FROM alpine:3.19
|
||||
|
||||
WORKDIR /root
|
||||
|
||||
RUN apk add --no-cache ca-certificates
|
||||
RUN apk add --no-cache ca-certificates && \
|
||||
chmod a+rw /var/lock
|
||||
|
||||
COPY --from=builder /app/cmd/backup/backup /usr/bin/backup
|
||||
COPY --chmod=755 ./entrypoint.sh /root/
|
||||
|
||||
ENTRYPOINT ["/root/entrypoint.sh"]
|
||||
ENTRYPOINT ["/usr/bin/backup", "-foreground"]
|
||||
|
||||
@@ -16,23 +16,23 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/klauspost/pgzip"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"github.com/klauspost/pgzip"
|
||||
"github.com/offen/docker-volume-backup/internal/errwrap"
|
||||
)
|
||||
|
||||
func createArchive(files []string, inputFilePath, outputFilePath string, compression string, compressionConcurrency int) error {
|
||||
inputFilePath = stripTrailingSlashes(inputFilePath)
|
||||
inputFilePath, outputFilePath, err := makeAbsolute(inputFilePath, outputFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("createArchive: error transposing given file paths: %w", err)
|
||||
return errwrap.Wrap(err, "error transposing given file paths")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(outputFilePath), 0755); err != nil {
|
||||
return fmt.Errorf("createArchive: error creating output file path: %w", err)
|
||||
return errwrap.Wrap(err, "error creating output file path")
|
||||
}
|
||||
|
||||
if err := compress(files, outputFilePath, filepath.Dir(inputFilePath), compression, compressionConcurrency); err != nil {
|
||||
return fmt.Errorf("createArchive: error creating archive: %w", err)
|
||||
return errwrap.Wrap(err, "error creating archive")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -58,35 +58,35 @@ func makeAbsolute(inputFilePath, outputFilePath string) (string, string, error)
|
||||
func compress(paths []string, outFilePath, subPath string, algo string, concurrency int) error {
|
||||
file, err := os.Create(outFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compress: error creating out file: %w", err)
|
||||
return errwrap.Wrap(err, "error creating out file")
|
||||
}
|
||||
|
||||
prefix := path.Dir(outFilePath)
|
||||
compressWriter, err := getCompressionWriter(file, algo, concurrency)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compress: error getting compression writer: %w", err)
|
||||
return errwrap.Wrap(err, "error getting compression writer")
|
||||
}
|
||||
tarWriter := tar.NewWriter(compressWriter)
|
||||
|
||||
for _, p := range paths {
|
||||
if err := writeTarball(p, tarWriter, prefix); err != nil {
|
||||
return fmt.Errorf("compress: error writing %s to archive: %w", p, err)
|
||||
return errwrap.Wrap(err, fmt.Sprintf("error writing %s to archive", p))
|
||||
}
|
||||
}
|
||||
|
||||
err = tarWriter.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("compress: error closing tar writer: %w", err)
|
||||
return errwrap.Wrap(err, "error closing tar writer")
|
||||
}
|
||||
|
||||
err = compressWriter.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("compress: error closing compression writer: %w", err)
|
||||
return errwrap.Wrap(err, "error closing compression writer")
|
||||
}
|
||||
|
||||
err = file.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("compress: error closing file: %w", err)
|
||||
return errwrap.Wrap(err, "error closing file")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -97,7 +97,7 @@ func getCompressionWriter(file *os.File, algo string, concurrency int) (io.Write
|
||||
case "gz":
|
||||
w, err := pgzip.NewWriterLevel(file, 5)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getCompressionWriter: gzip error: %w", err)
|
||||
return nil, errwrap.Wrap(err, "gzip error")
|
||||
}
|
||||
|
||||
if concurrency == 0 {
|
||||
@@ -105,25 +105,25 @@ func getCompressionWriter(file *os.File, algo string, concurrency int) (io.Write
|
||||
}
|
||||
|
||||
if err := w.SetConcurrency(1<<20, concurrency); err != nil {
|
||||
return nil, fmt.Errorf("getCompressionWriter: error setting concurrency: %w", err)
|
||||
return nil, errwrap.Wrap(err, "error setting concurrency")
|
||||
}
|
||||
|
||||
return w, nil
|
||||
case "zst":
|
||||
compressWriter, err := zstd.NewWriter(file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getCompressionWriter: zstd error: %w", err)
|
||||
return nil, errwrap.Wrap(err, "zstd error")
|
||||
}
|
||||
return compressWriter, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("getCompressionWriter: unsupported compression algorithm: %s", algo)
|
||||
return nil, errwrap.Wrap(nil, fmt.Sprintf("unsupported compression algorithm: %s", algo))
|
||||
}
|
||||
}
|
||||
|
||||
func writeTarball(path string, tarWriter *tar.Writer, prefix string) error {
|
||||
fileInfo, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("writeTarball: error getting file infor for %s: %w", path, err)
|
||||
return errwrap.Wrap(err, fmt.Sprintf("error getting file info for %s", path))
|
||||
}
|
||||
|
||||
if fileInfo.Mode()&os.ModeSocket == os.ModeSocket {
|
||||
@@ -134,19 +134,19 @@ func writeTarball(path string, tarWriter *tar.Writer, prefix string) error {
|
||||
if fileInfo.Mode()&os.ModeSymlink == os.ModeSymlink {
|
||||
var err error
|
||||
if link, err = os.Readlink(path); err != nil {
|
||||
return fmt.Errorf("writeTarball: error resolving symlink %s: %w", path, err)
|
||||
return errwrap.Wrap(err, fmt.Sprintf("error resolving symlink %s", path))
|
||||
}
|
||||
}
|
||||
|
||||
header, err := tar.FileInfoHeader(fileInfo, link)
|
||||
if err != nil {
|
||||
return fmt.Errorf("writeTarball: error getting file info header: %w", err)
|
||||
return errwrap.Wrap(err, "error getting file info header")
|
||||
}
|
||||
header.Name = strings.TrimPrefix(path, prefix)
|
||||
|
||||
err = tarWriter.WriteHeader(header)
|
||||
if err != nil {
|
||||
return fmt.Errorf("writeTarball: error writing file info header: %w", err)
|
||||
return errwrap.Wrap(err, "error writing file info header")
|
||||
}
|
||||
|
||||
if !fileInfo.Mode().IsRegular() {
|
||||
@@ -155,13 +155,13 @@ func writeTarball(path string, tarWriter *tar.Writer, prefix string) error {
|
||||
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("writeTarball: error opening %s: %w", path, err)
|
||||
return errwrap.Wrap(err, fmt.Sprintf("error opening %s", path))
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(tarWriter, file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("writeTarball: error copying %s to tar writer: %w", path, err)
|
||||
return errwrap.Wrap(err, fmt.Sprintf("error copying %s to tar writer", path))
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
156
cmd/backup/command.go
Normal file
156
cmd/backup/command.go
Normal file
@@ -0,0 +1,156 @@
|
||||
// Copyright 2024 - Offen Authors <hioffen@posteo.de>
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/offen/docker-volume-backup/internal/errwrap"
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
type command struct {
|
||||
logger *slog.Logger
|
||||
schedules []cron.EntryID
|
||||
cr *cron.Cron
|
||||
reload chan struct{}
|
||||
}
|
||||
|
||||
func newCommand() *command {
|
||||
return &command{
|
||||
logger: slog.New(slog.NewTextHandler(os.Stdout, nil)),
|
||||
}
|
||||
}
|
||||
|
||||
// runAsCommand executes a backup run for each configuration that is available
|
||||
// and then returns
|
||||
func (c *command) runAsCommand() error {
|
||||
configurations, err := sourceConfiguration(configStrategyEnv)
|
||||
if err != nil {
|
||||
return errwrap.Wrap(err, "error loading env vars")
|
||||
}
|
||||
|
||||
for _, config := range configurations {
|
||||
if err := runScript(config); err != nil {
|
||||
return errwrap.Wrap(err, "error running script")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type foregroundOpts struct {
|
||||
profileCronExpression string
|
||||
}
|
||||
|
||||
// runInForeground starts the program as a long running process, scheduling
|
||||
// a job for each configuration that is available.
|
||||
func (c *command) runInForeground(opts foregroundOpts) error {
|
||||
c.cr = cron.New(
|
||||
cron.WithParser(
|
||||
cron.NewParser(
|
||||
cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
if err := c.schedule(configStrategyConfd); err != nil {
|
||||
return errwrap.Wrap(err, "error scheduling")
|
||||
}
|
||||
|
||||
if opts.profileCronExpression != "" {
|
||||
if _, err := c.cr.AddFunc(opts.profileCronExpression, c.profile); err != nil {
|
||||
return errwrap.Wrap(err, "error adding profiling job")
|
||||
}
|
||||
}
|
||||
|
||||
var quit = make(chan os.Signal, 1)
|
||||
c.reload = make(chan struct{}, 1)
|
||||
signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)
|
||||
c.cr.Start()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-quit:
|
||||
ctx := c.cr.Stop()
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
case <-c.reload:
|
||||
if err := c.schedule(configStrategyConfd); err != nil {
|
||||
return errwrap.Wrap(err, "error reloading configuration")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// schedule wipes all existing schedules and enqueues all schedules available
|
||||
// using the given configuration strategy
|
||||
func (c *command) schedule(strategy configStrategy) error {
|
||||
for _, id := range c.schedules {
|
||||
c.cr.Remove(id)
|
||||
}
|
||||
|
||||
configurations, err := sourceConfiguration(strategy)
|
||||
if err != nil {
|
||||
return errwrap.Wrap(err, "error sourcing configuration")
|
||||
}
|
||||
|
||||
for _, cfg := range configurations {
|
||||
config := cfg
|
||||
id, err := c.cr.AddFunc(config.BackupCronExpression, func() {
|
||||
c.logger.Info(
|
||||
fmt.Sprintf(
|
||||
"Now running script on schedule %s",
|
||||
config.BackupCronExpression,
|
||||
),
|
||||
)
|
||||
|
||||
if err := runScript(config); err != nil {
|
||||
c.logger.Error(
|
||||
fmt.Sprintf(
|
||||
"Unexpected error running schedule %s: %v",
|
||||
config.BackupCronExpression,
|
||||
errwrap.Unwrap(err),
|
||||
),
|
||||
"error",
|
||||
err,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return errwrap.Wrap(err, fmt.Sprintf("error adding schedule %s", config.BackupCronExpression))
|
||||
}
|
||||
c.logger.Info(fmt.Sprintf("Successfully scheduled backup %s with expression %s", config.source, config.BackupCronExpression))
|
||||
if ok := checkCronSchedule(config.BackupCronExpression); !ok {
|
||||
c.logger.Warn(
|
||||
fmt.Sprintf("Scheduled cron expression %s will never run, is this intentional?", config.BackupCronExpression),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return errwrap.Wrap(err, "error scheduling")
|
||||
}
|
||||
c.schedules = append(c.schedules, id)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// must exits the program when passed an error. It should be the only
|
||||
// place where the application exits forcefully.
|
||||
func (c *command) must(err error) {
|
||||
if err != nil {
|
||||
c.logger.Error(
|
||||
fmt.Sprintf("Fatal error running command: %v", errwrap.Unwrap(err)),
|
||||
"error",
|
||||
err,
|
||||
)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/offen/docker-volume-backup/internal/errwrap"
|
||||
)
|
||||
|
||||
// Config holds all configuration values that are expected to be set
|
||||
@@ -34,10 +36,13 @@ type Config struct {
|
||||
BackupFilenameExpand bool `split_words:"true"`
|
||||
BackupLatestSymlink string `split_words:"true"`
|
||||
BackupArchive string `split_words:"true" default:"/archive"`
|
||||
BackupCronExpression string `split_words:"true" default:"@daily"`
|
||||
BackupRetentionDays int32 `split_words:"true" default:"-1"`
|
||||
BackupPruningLeeway time.Duration `split_words:"true" default:"1m"`
|
||||
BackupPruningPrefix string `split_words:"true"`
|
||||
BackupStopContainerLabel string `split_words:"true" default:"true"`
|
||||
BackupStopContainerLabel string `split_words:"true"`
|
||||
BackupStopDuringBackupLabel string `split_words:"true" default:"true"`
|
||||
BackupStopServiceTimeout time.Duration `split_words:"true" default:"5m"`
|
||||
BackupFromSnapshot bool `split_words:"true"`
|
||||
BackupExcludeRegexp RegexpDecoder `split_words:"true"`
|
||||
BackupSkipBackendsFromPrune []string `split_words:"true"`
|
||||
@@ -77,6 +82,8 @@ type Config struct {
|
||||
DropboxAppSecret string `split_words:"true"`
|
||||
DropboxRemotePath string `split_words:"true"`
|
||||
DropboxConcurrencyLevel NaturalNumber `split_words:"true" default:"6"`
|
||||
source string
|
||||
additionalEnvVars map[string]string
|
||||
}
|
||||
|
||||
type CompressionType string
|
||||
@@ -87,7 +94,7 @@ func (c *CompressionType) Decode(v string) error {
|
||||
*c = CompressionType(v)
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("config: error decoding compression type %s", v)
|
||||
return errwrap.Wrap(nil, fmt.Sprintf("error decoding compression type %s", v))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +117,7 @@ func (c *CertDecoder) Decode(v string) error {
|
||||
block, _ := pem.Decode(content)
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config: error parsing certificate: %w", err)
|
||||
return errwrap.Wrap(err, "error parsing certificate")
|
||||
}
|
||||
*c = CertDecoder{Cert: cert}
|
||||
return nil
|
||||
@@ -126,7 +133,7 @@ func (r *RegexpDecoder) Decode(v string) error {
|
||||
}
|
||||
re, err := regexp.Compile(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config: error compiling given regexp `%s`: %w", v, err)
|
||||
return errwrap.Wrap(err, fmt.Sprintf("error compiling given regexp `%s`", v))
|
||||
}
|
||||
*r = RegexpDecoder{Re: re}
|
||||
return nil
|
||||
@@ -138,10 +145,10 @@ type NaturalNumber int
|
||||
func (n *NaturalNumber) Decode(v string) error {
|
||||
asInt, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config: error converting %s to int", v)
|
||||
return errwrap.Wrap(nil, fmt.Sprintf("error converting %s to int", v))
|
||||
}
|
||||
if asInt <= 0 {
|
||||
return fmt.Errorf("config: expected a natural number, got %d", asInt)
|
||||
return errwrap.Wrap(nil, fmt.Sprintf("expected a natural number, got %d", asInt))
|
||||
}
|
||||
*n = NaturalNumber(asInt)
|
||||
return nil
|
||||
@@ -157,10 +164,10 @@ type WholeNumber int
|
||||
func (n *WholeNumber) Decode(v string) error {
|
||||
asInt, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config: error converting %s to int", v)
|
||||
return errwrap.Wrap(nil, fmt.Sprintf("error converting %s to int", v))
|
||||
}
|
||||
if asInt < 0 {
|
||||
return fmt.Errorf("config: expected a whole, positive number, including zero. Got %d", asInt)
|
||||
return errwrap.Wrap(nil, fmt.Sprintf("expected a whole, positive number, including zero. Got %d", asInt))
|
||||
}
|
||||
*n = WholeNumber(asInt)
|
||||
return nil
|
||||
@@ -169,3 +176,40 @@ func (n *WholeNumber) Decode(v string) error {
|
||||
func (n *WholeNumber) Int() int {
|
||||
return int(*n)
|
||||
}
|
||||
|
||||
type envVarLookup struct {
|
||||
ok bool
|
||||
key string
|
||||
value string
|
||||
}
|
||||
|
||||
// applyEnv sets the values in `additionalEnvVars` as environment variables.
|
||||
// It returns a function that reverts all values that have been set to its
|
||||
// previous state.
|
||||
func (c *Config) applyEnv() (func() error, error) {
|
||||
lookups := []envVarLookup{}
|
||||
|
||||
unset := func() error {
|
||||
for _, lookup := range lookups {
|
||||
if !lookup.ok {
|
||||
if err := os.Unsetenv(lookup.key); err != nil {
|
||||
return errwrap.Wrap(err, fmt.Sprintf("error unsetting env var %s", lookup.key))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := os.Setenv(lookup.key, lookup.value); err != nil {
|
||||
return errwrap.Wrap(err, fmt.Sprintf("error setting back env var %s", lookup.key))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for key, value := range c.additionalEnvVars {
|
||||
current, ok := os.LookupEnv(key)
|
||||
lookups = append(lookups, envVarLookup{ok: ok, key: key, value: current})
|
||||
if err := os.Setenv(key, value); err != nil {
|
||||
return unset, errwrap.Wrap(err, "error setting env var")
|
||||
}
|
||||
}
|
||||
return unset, nil
|
||||
}
|
||||
|
||||
161
cmd/backup/config_provider.go
Normal file
161
cmd/backup/config_provider.go
Normal file
@@ -0,0 +1,161 @@
|
||||
// Copyright 2024 - Offen Authors <hioffen@posteo.de>
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/offen/docker-volume-backup/internal/errwrap"
|
||||
"github.com/offen/envconfig"
|
||||
shell "mvdan.cc/sh/v3/shell"
|
||||
)
|
||||
|
||||
type configStrategy string
|
||||
|
||||
const (
|
||||
configStrategyEnv configStrategy = "env"
|
||||
configStrategyConfd configStrategy = "confd"
|
||||
)
|
||||
|
||||
// sourceConfiguration returns a list of config objects using the given
|
||||
// strategy. It should be the single entrypoint for retrieving configuration
|
||||
// for all consumers.
|
||||
func sourceConfiguration(strategy configStrategy) ([]*Config, error) {
|
||||
switch strategy {
|
||||
case configStrategyEnv:
|
||||
c, err := loadConfigFromEnvVars()
|
||||
return []*Config{c}, err
|
||||
case configStrategyConfd:
|
||||
cs, err := loadConfigsFromEnvFiles("/etc/dockervolumebackup/conf.d")
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return sourceConfiguration(configStrategyEnv)
|
||||
}
|
||||
return nil, errwrap.Wrap(err, "error loading config files")
|
||||
}
|
||||
return cs, nil
|
||||
default:
|
||||
return nil, errwrap.Wrap(nil, fmt.Sprintf("received unknown config strategy: %v", strategy))
|
||||
}
|
||||
}
|
||||
|
||||
// envProxy is a function that mimics os.LookupEnv but can read values from any other source
|
||||
type envProxy func(string) (string, bool)
|
||||
|
||||
// loadConfig creates a config object using the given lookup function
|
||||
func loadConfig(lookup envProxy) (*Config, error) {
|
||||
envconfig.Lookup = func(key string) (string, bool) {
|
||||
value, okValue := lookup(key)
|
||||
location, okFile := lookup(key + "_FILE")
|
||||
|
||||
switch {
|
||||
case okValue && !okFile: // only value
|
||||
return value, true
|
||||
case !okValue && okFile: // only file
|
||||
contents, err := os.ReadFile(location)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return string(contents), true
|
||||
case okValue && okFile: // both
|
||||
return "", false
|
||||
default: // neither, ignore
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
var c = &Config{}
|
||||
if err := envconfig.Process("", c); err != nil {
|
||||
return nil, errwrap.Wrap(err, "failed to process configuration values")
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func loadConfigFromEnvVars() (*Config, error) {
|
||||
c, err := loadConfig(os.LookupEnv)
|
||||
if err != nil {
|
||||
return nil, errwrap.Wrap(err, "error loading config from environment")
|
||||
}
|
||||
c.source = "from environment"
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func loadConfigsFromEnvFiles(directory string) ([]*Config, error) {
|
||||
items, err := os.ReadDir(directory)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errwrap.Wrap(err, "failed to read files from env directory")
|
||||
}
|
||||
|
||||
configs := []*Config{}
|
||||
for _, item := range items {
|
||||
if item.IsDir() {
|
||||
continue
|
||||
}
|
||||
p := filepath.Join(directory, item.Name())
|
||||
envFile, err := source(p)
|
||||
if err != nil {
|
||||
return nil, errwrap.Wrap(err, fmt.Sprintf("error reading config file %s", p))
|
||||
}
|
||||
lookup := func(key string) (string, bool) {
|
||||
val, ok := envFile[key]
|
||||
if ok {
|
||||
return val, ok
|
||||
}
|
||||
return os.LookupEnv(key)
|
||||
}
|
||||
c, err := loadConfig(lookup)
|
||||
if err != nil {
|
||||
return nil, errwrap.Wrap(err, fmt.Sprintf("error loading config from file %s", p))
|
||||
}
|
||||
c.source = item.Name()
|
||||
c.additionalEnvVars = envFile
|
||||
configs = append(configs, c)
|
||||
}
|
||||
|
||||
return configs, nil
|
||||
}
|
||||
|
||||
// source tries to mimic the pre v2.37.0 behavior of calling
|
||||
// `set +a; source $path; set -a` and returns the env vars as a map
|
||||
func source(path string) (map[string]string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, errwrap.Wrap(err, fmt.Sprintf("error opening %s", path))
|
||||
}
|
||||
|
||||
result := map[string]string{}
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
withExpansion, err := shell.Expand(line, nil)
|
||||
if err != nil {
|
||||
return nil, errwrap.Wrap(err, "error expanding env")
|
||||
}
|
||||
m, err := godotenv.Unmarshal(withExpansion)
|
||||
if err != nil {
|
||||
return nil, errwrap.Wrap(err, fmt.Sprintf("error sourcing %s", path))
|
||||
}
|
||||
for key, value := range m {
|
||||
currentValue, currentOk := os.LookupEnv(key)
|
||||
defer func() {
|
||||
if currentOk {
|
||||
os.Setenv(key, currentValue)
|
||||
return
|
||||
}
|
||||
os.Unsetenv(key)
|
||||
}()
|
||||
result[key] = value
|
||||
os.Setenv(key, value)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
68
cmd/backup/config_provider_test.go
Normal file
68
cmd/backup/config_provider_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSource(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expectError bool
|
||||
expectedOutput map[string]string
|
||||
}{
|
||||
{
|
||||
"default",
|
||||
"testdata/default.env",
|
||||
false,
|
||||
map[string]string{
|
||||
"FOO": "bar",
|
||||
"BAZ": "qux",
|
||||
},
|
||||
},
|
||||
{
|
||||
"not found",
|
||||
"testdata/nope.env",
|
||||
true,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"braces",
|
||||
"testdata/braces.env",
|
||||
false,
|
||||
map[string]string{
|
||||
"FOO": "qux",
|
||||
"BAR": "xxx",
|
||||
"BAZ": "",
|
||||
},
|
||||
},
|
||||
{
|
||||
"expansion",
|
||||
"testdata/expansion.env",
|
||||
false,
|
||||
map[string]string{
|
||||
"BAR": "xxx",
|
||||
"FOO": "xxx",
|
||||
"BAZ": "xxx",
|
||||
"QUX": "yyy",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
os.Setenv("QUX", "yyy")
|
||||
defer os.Unsetenv("QUX")
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result, err := source(test.input)
|
||||
if (err != nil) != test.expectError {
|
||||
t.Errorf("Unexpected error value %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(test.expectedOutput, result) {
|
||||
t.Errorf("Expected %v, got %v", test.expectedOutput, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
41
cmd/backup/copy_archive.go
Normal file
41
cmd/backup/copy_archive.go
Normal file
@@ -0,0 +1,41 @@
|
||||
// Copyright 2024 - Offen Authors <hioffen@posteo.de>
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"github.com/offen/docker-volume-backup/internal/errwrap"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
// copyArchive makes sure the backup file is copied to both local and remote locations
|
||||
// as per the given configuration.
|
||||
func (s *script) copyArchive() error {
|
||||
_, name := path.Split(s.file)
|
||||
if stat, err := os.Stat(s.file); err != nil {
|
||||
return errwrap.Wrap(err, "unable to stat backup file")
|
||||
} else {
|
||||
size := stat.Size()
|
||||
s.stats.BackupFile = BackupFileStats{
|
||||
Size: uint64(size),
|
||||
Name: name,
|
||||
FullPath: s.file,
|
||||
}
|
||||
}
|
||||
|
||||
eg := errgroup.Group{}
|
||||
for _, backend := range s.storages {
|
||||
b := backend
|
||||
eg.Go(func() error {
|
||||
return b.Copy(s.file)
|
||||
})
|
||||
}
|
||||
if err := eg.Wait(); err != nil {
|
||||
return errwrap.Wrap(err, "error copying archive")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
88
cmd/backup/create_archive.go
Normal file
88
cmd/backup/create_archive.go
Normal file
@@ -0,0 +1,88 @@
|
||||
// Copyright 2024 - Offen Authors <hioffen@posteo.de>
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/offen/docker-volume-backup/internal/errwrap"
|
||||
"github.com/otiai10/copy"
|
||||
)
|
||||
|
||||
// createArchive creates a tar archive of the configured backup location and
|
||||
// saves it to disk.
|
||||
func (s *script) createArchive() error {
|
||||
backupSources := s.c.BackupSources
|
||||
|
||||
if s.c.BackupFromSnapshot {
|
||||
s.logger.Warn(
|
||||
"Using BACKUP_FROM_SNAPSHOT has been deprecated and will be removed in the next major version.",
|
||||
)
|
||||
s.logger.Warn(
|
||||
"Please use `archive-pre` and `archive-post` commands to prepare your backup sources. Refer to the documentation for an upgrade guide.",
|
||||
)
|
||||
backupSources = filepath.Join("/tmp", s.c.BackupSources)
|
||||
// copy before compressing guard against a situation where backup folder's content are still growing.
|
||||
s.registerHook(hookLevelPlumbing, func(error) error {
|
||||
if err := remove(backupSources); err != nil {
|
||||
return errwrap.Wrap(err, "error removing snapshot")
|
||||
}
|
||||
s.logger.Info(
|
||||
fmt.Sprintf("Removed snapshot `%s`.", backupSources),
|
||||
)
|
||||
return nil
|
||||
})
|
||||
if err := copy.Copy(s.c.BackupSources, backupSources, copy.Options{
|
||||
PreserveTimes: true,
|
||||
PreserveOwner: true,
|
||||
}); err != nil {
|
||||
return errwrap.Wrap(err, "error creating snapshot")
|
||||
}
|
||||
s.logger.Info(
|
||||
fmt.Sprintf("Created snapshot of `%s` at `%s`.", s.c.BackupSources, backupSources),
|
||||
)
|
||||
}
|
||||
|
||||
tarFile := s.file
|
||||
s.registerHook(hookLevelPlumbing, func(error) error {
|
||||
if err := remove(tarFile); err != nil {
|
||||
return errwrap.Wrap(err, "error removing tar file")
|
||||
}
|
||||
s.logger.Info(
|
||||
fmt.Sprintf("Removed tar file `%s`.", tarFile),
|
||||
)
|
||||
return nil
|
||||
})
|
||||
|
||||
backupPath, err := filepath.Abs(stripTrailingSlashes(backupSources))
|
||||
if err != nil {
|
||||
return errwrap.Wrap(err, "error getting absolute path")
|
||||
}
|
||||
|
||||
var filesEligibleForBackup []string
|
||||
if err := filepath.WalkDir(backupPath, func(path string, di fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if s.c.BackupExcludeRegexp.Re != nil && s.c.BackupExcludeRegexp.Re.MatchString(path) {
|
||||
return nil
|
||||
}
|
||||
filesEligibleForBackup = append(filesEligibleForBackup, path)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return errwrap.Wrap(err, "error walking filesystem tree")
|
||||
}
|
||||
|
||||
if err := createArchive(filesEligibleForBackup, backupSources, tarFile, s.c.BackupCompression.String(), s.c.GzipParallelism.Int()); err != nil {
|
||||
return errwrap.Wrap(err, "error compressing backup folder")
|
||||
}
|
||||
|
||||
s.logger.Info(
|
||||
fmt.Sprintf("Created backup of `%s` at `%s`.", backupSources, tarFile),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
64
cmd/backup/encrypt_archive.go
Normal file
64
cmd/backup/encrypt_archive.go
Normal file
@@ -0,0 +1,64 @@
|
||||
// Copyright 2024 - Offen Authors <hioffen@posteo.de>
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
openpgp "github.com/ProtonMail/go-crypto/openpgp/v2"
|
||||
"github.com/offen/docker-volume-backup/internal/errwrap"
|
||||
)
|
||||
|
||||
// encryptArchive encrypts the backup file using PGP and the configured passphrase.
|
||||
// In case no passphrase is given it returns early, leaving the backup file
|
||||
// untouched.
|
||||
func (s *script) encryptArchive() error {
|
||||
if s.c.GpgPassphrase == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
gpgFile := fmt.Sprintf("%s.gpg", s.file)
|
||||
s.registerHook(hookLevelPlumbing, func(error) error {
|
||||
if err := remove(gpgFile); err != nil {
|
||||
return errwrap.Wrap(err, "error removing gpg file")
|
||||
}
|
||||
s.logger.Info(
|
||||
fmt.Sprintf("Removed GPG file `%s`.", gpgFile),
|
||||
)
|
||||
return nil
|
||||
})
|
||||
|
||||
outFile, err := os.Create(gpgFile)
|
||||
if err != nil {
|
||||
return errwrap.Wrap(err, "error opening out file")
|
||||
}
|
||||
defer outFile.Close()
|
||||
|
||||
_, name := path.Split(s.file)
|
||||
dst, err := openpgp.SymmetricallyEncrypt(outFile, []byte(s.c.GpgPassphrase), &openpgp.FileHints{
|
||||
FileName: name,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return errwrap.Wrap(err, "error encrypting backup file")
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
src, err := os.Open(s.file)
|
||||
if err != nil {
|
||||
return errwrap.Wrap(err, fmt.Sprintf("error opening backup file `%s`", s.file))
|
||||
}
|
||||
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
return errwrap.Wrap(err, "error writing ciphertext to file")
|
||||
}
|
||||
|
||||
s.file = gpgFile
|
||||
s.logger.Info(
|
||||
fmt.Sprintf("Encrypted backup using given passphrase, saving as `%s`.", s.file),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
@@ -9,6 +9,7 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/pkg/stdcopy"
|
||||
"github.com/offen/docker-volume-backup/internal/errwrap"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
@@ -34,12 +36,12 @@ func (s *script) exec(containerRef string, command string, user string) ([]byte,
|
||||
User: user,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("exec: error creating container exec: %w", err)
|
||||
return nil, nil, errwrap.Wrap(err, "error creating container exec")
|
||||
}
|
||||
|
||||
resp, err := s.cli.ContainerExecAttach(context.Background(), execID.ID, types.ExecStartCheck{})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("exec: error attaching container exec: %w", err)
|
||||
return nil, nil, errwrap.Wrap(err, "error attaching container exec")
|
||||
}
|
||||
defer resp.Close()
|
||||
|
||||
@@ -52,25 +54,25 @@ func (s *script) exec(containerRef string, command string, user string) ([]byte,
|
||||
}()
|
||||
|
||||
if err := <-outputDone; err != nil {
|
||||
return nil, nil, fmt.Errorf("exec: error demultiplexing output: %w", err)
|
||||
return nil, nil, errwrap.Wrap(err, "error demultiplexing output")
|
||||
}
|
||||
|
||||
stdout, err := io.ReadAll(&outBuf)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("exec: error reading stdout: %w", err)
|
||||
return nil, nil, errwrap.Wrap(err, "error reading stdout")
|
||||
}
|
||||
stderr, err := io.ReadAll(&errBuf)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("exec: error reading stderr: %w", err)
|
||||
return nil, nil, errwrap.Wrap(err, "error reading stderr")
|
||||
}
|
||||
|
||||
res, err := s.cli.ContainerExecInspect(context.Background(), execID.ID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("exec: error inspecting container exec: %w", err)
|
||||
return nil, nil, errwrap.Wrap(err, "error inspecting container exec")
|
||||
}
|
||||
|
||||
if res.ExitCode > 0 {
|
||||
return stdout, stderr, fmt.Errorf("exec: running command exited %d", res.ExitCode)
|
||||
return stdout, stderr, errwrap.Wrap(nil, fmt.Sprintf("running command exited %d", res.ExitCode))
|
||||
}
|
||||
|
||||
return stdout, stderr, nil
|
||||
@@ -90,7 +92,7 @@ func (s *script) runLabeledCommands(label string) error {
|
||||
Filters: filters.NewArgs(f...),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("runLabeledCommands: error querying for containers: %w", err)
|
||||
return errwrap.Wrap(err, "error querying for containers")
|
||||
}
|
||||
|
||||
var hasDeprecatedContainers bool
|
||||
@@ -103,7 +105,7 @@ func (s *script) runLabeledCommands(label string) error {
|
||||
Filters: filters.NewArgs(f...),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("runLabeledCommands: error querying for containers: %w", err)
|
||||
return errwrap.Wrap(err, "error querying for containers")
|
||||
}
|
||||
if len(deprecatedContainers) != 0 {
|
||||
hasDeprecatedContainers = true
|
||||
@@ -120,7 +122,7 @@ func (s *script) runLabeledCommands(label string) error {
|
||||
Filters: filters.NewArgs(f...),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("runLabeledCommands: error querying for containers: %w", err)
|
||||
return errwrap.Wrap(err, "error querying for containers")
|
||||
}
|
||||
if len(deprecatedContainers) != 0 {
|
||||
hasDeprecatedContainers = true
|
||||
@@ -163,14 +165,14 @@ func (s *script) runLabeledCommands(label string) error {
|
||||
os.Stdout.Write(stdout)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("runLabeledCommands: error executing command: %w", err)
|
||||
return errwrap.Wrap(err, "error executing command")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return fmt.Errorf("runLabeledCommands: error from errgroup: %w", err)
|
||||
return errwrap.Wrap(err, "error from errgroup")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -188,13 +190,17 @@ func (s *script) withLabeledCommands(step lifecyclePhase, cb func() error) func(
|
||||
if s.cli == nil {
|
||||
return cb
|
||||
}
|
||||
return func() error {
|
||||
if err := s.runLabeledCommands(fmt.Sprintf("docker-volume-backup.%s-pre", step)); err != nil {
|
||||
return fmt.Errorf("withLabeledCommands: %s: error running pre commands: %w", step, err)
|
||||
return func() (err error) {
|
||||
if err = s.runLabeledCommands(fmt.Sprintf("docker-volume-backup.%s-pre", step)); err != nil {
|
||||
err = errwrap.Wrap(err, fmt.Sprintf("error running %s-pre commands", step))
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
s.must(s.runLabeledCommands(fmt.Sprintf("docker-volume-backup.%s-post", step)))
|
||||
if derr := s.runLabeledCommands(fmt.Sprintf("docker-volume-backup.%s-post", step)); derr != nil {
|
||||
err = errors.Join(err, errwrap.Wrap(derr, fmt.Sprintf("error running %s-post commands", step)))
|
||||
}
|
||||
}()
|
||||
return cb()
|
||||
err = cb()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@ package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/offen/docker-volume-backup/internal/errwrap"
|
||||
)
|
||||
|
||||
// hook contains a queued action that can be trigger them when the script
|
||||
@@ -47,7 +48,7 @@ func (s *script) runHooks(err error) error {
|
||||
continue
|
||||
}
|
||||
if actionErr := hook.action(err); actionErr != nil {
|
||||
actionErrors = append(actionErrors, fmt.Errorf("runHooks: error running hook: %w", actionErr))
|
||||
actionErrors = append(actionErrors, errwrap.Wrap(actionErr, "error running hook"))
|
||||
}
|
||||
}
|
||||
if len(actionErrors) != 0 {
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/flock"
|
||||
"github.com/offen/docker-volume-backup/internal/errwrap"
|
||||
)
|
||||
|
||||
// lock opens a lockfile at the given location, keeping it locked until the
|
||||
@@ -31,7 +31,7 @@ func (s *script) lock(lockfile string) (func() error, error) {
|
||||
for {
|
||||
acquired, err := fileLock.TryLock()
|
||||
if err != nil {
|
||||
return noop, fmt.Errorf("lock: error trying to lock: %w", err)
|
||||
return noop, errwrap.Wrap(err, "error trying to lock")
|
||||
}
|
||||
if acquired {
|
||||
if s.encounteredLock {
|
||||
@@ -54,7 +54,7 @@ func (s *script) lock(lockfile string) (func() error, error) {
|
||||
case <-retry.C:
|
||||
continue
|
||||
case <-deadline.C:
|
||||
return noop, errors.New("lock: timed out waiting for lockfile to become available")
|
||||
return noop, errwrap.Wrap(nil, "timed out waiting for lockfile to become available")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,60 +4,21 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"flag"
|
||||
)
|
||||
|
||||
func main() {
|
||||
s, err := newScript()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
foreground := flag.Bool("foreground", false, "run the tool in the foreground")
|
||||
profile := flag.String("profile", "", "collect runtime metrics and log them periodically on the given cron expression")
|
||||
flag.Parse()
|
||||
|
||||
c := newCommand()
|
||||
if *foreground {
|
||||
opts := foregroundOpts{
|
||||
profileCronExpression: *profile,
|
||||
}
|
||||
c.must(c.runInForeground(opts))
|
||||
} else {
|
||||
c.must(c.runAsCommand())
|
||||
}
|
||||
|
||||
unlock, err := s.lock("/var/lock/dockervolumebackup.lock")
|
||||
defer s.must(unlock())
|
||||
s.must(err)
|
||||
|
||||
defer func() {
|
||||
if pArg := recover(); pArg != nil {
|
||||
if err, ok := pArg.(error); ok {
|
||||
if hookErr := s.runHooks(err); hookErr != nil {
|
||||
s.logger.Error(
|
||||
fmt.Sprintf("An error occurred calling the registered hooks: %s", hookErr),
|
||||
)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
panic(pArg)
|
||||
}
|
||||
|
||||
if err := s.runHooks(nil); err != nil {
|
||||
s.logger.Error(
|
||||
fmt.Sprintf(
|
||||
"Backup procedure ran successfully, but an error ocurred calling the registered hooks: %v",
|
||||
err,
|
||||
),
|
||||
)
|
||||
os.Exit(1)
|
||||
}
|
||||
s.logger.Info("Finished running backup tasks.")
|
||||
}()
|
||||
|
||||
s.must(s.withLabeledCommands(lifecyclePhaseArchive, func() error {
|
||||
restartContainers, err := s.stopContainers()
|
||||
// The mechanism for restarting containers is not using hooks as it
|
||||
// should happen as soon as possible (i.e. before uploading backups or
|
||||
// similar).
|
||||
defer func() {
|
||||
s.must(restartContainers())
|
||||
}()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.createArchive()
|
||||
})())
|
||||
|
||||
s.must(s.withLabeledCommands(lifecyclePhaseProcess, s.encryptArchive)())
|
||||
s.must(s.withLabeledCommands(lifecyclePhaseCopy, s.copyArchive)())
|
||||
s.must(s.withLabeledCommands(lifecyclePhasePrune, s.pruneBackups)())
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
sTypes "github.com/containrrr/shoutrrr/pkg/types"
|
||||
"github.com/offen/docker-volume-backup/internal/errwrap"
|
||||
)
|
||||
|
||||
//go:embed notifications.tmpl
|
||||
@@ -37,16 +38,16 @@ func (s *script) notify(titleTemplate string, bodyTemplate string, err error) er
|
||||
|
||||
titleBuf := &bytes.Buffer{}
|
||||
if err := s.template.ExecuteTemplate(titleBuf, titleTemplate, params); err != nil {
|
||||
return fmt.Errorf("notify: error executing %s template: %w", titleTemplate, err)
|
||||
return errwrap.Wrap(err, fmt.Sprintf("error executing %s template", titleTemplate))
|
||||
}
|
||||
|
||||
bodyBuf := &bytes.Buffer{}
|
||||
if err := s.template.ExecuteTemplate(bodyBuf, bodyTemplate, params); err != nil {
|
||||
return fmt.Errorf("notify: error executing %s template: %w", bodyTemplate, err)
|
||||
return errwrap.Wrap(err, fmt.Sprintf("error executing %s template", bodyTemplate))
|
||||
}
|
||||
|
||||
if err := s.sendNotification(titleBuf.String(), bodyBuf.String()); err != nil {
|
||||
return fmt.Errorf("notify: error notifying: %w", err)
|
||||
return errwrap.Wrap(err, "error sending notification")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -70,7 +71,7 @@ func (s *script) sendNotification(title, body string) error {
|
||||
}
|
||||
}
|
||||
if len(errs) != 0 {
|
||||
return fmt.Errorf("sendNotification: error sending message: %w", errors.Join(errs...))
|
||||
return errwrap.Wrap(errors.Join(errs...), "error sending message")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
24
cmd/backup/profile.go
Normal file
24
cmd/backup/profile.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright 2024 - Offen Authors <hioffen@posteo.de>
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package main
|
||||
|
||||
import "runtime"
|
||||
|
||||
func (c *command) profile() {
|
||||
memStats := runtime.MemStats{}
|
||||
runtime.ReadMemStats(&memStats)
|
||||
c.logger.Info(
|
||||
"Collecting runtime information",
|
||||
"num_goroutines",
|
||||
runtime.NumGoroutine(),
|
||||
"memory_heap_alloc",
|
||||
formatBytes(memStats.HeapAlloc, false),
|
||||
"memory_heap_inuse",
|
||||
formatBytes(memStats.HeapInuse, false),
|
||||
"memory_heap_sys",
|
||||
formatBytes(memStats.HeapSys, false),
|
||||
"memory_heap_objects",
|
||||
memStats.HeapObjects,
|
||||
)
|
||||
}
|
||||
66
cmd/backup/prune_backups.go
Normal file
66
cmd/backup/prune_backups.go
Normal file
@@ -0,0 +1,66 @@
|
||||
// Copyright 2024 - Offen Authors <hioffen@posteo.de>
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/offen/docker-volume-backup/internal/errwrap"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
// pruneBackups rotates away backups from local and remote storages using
|
||||
// the given configuration. In case the given configuration would delete all
|
||||
// backups, it does nothing instead and logs a warning.
|
||||
func (s *script) pruneBackups() error {
|
||||
if s.c.BackupRetentionDays < 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
deadline := time.Now().AddDate(0, 0, -int(s.c.BackupRetentionDays)).Add(s.c.BackupPruningLeeway)
|
||||
|
||||
eg := errgroup.Group{}
|
||||
for _, backend := range s.storages {
|
||||
b := backend
|
||||
eg.Go(func() error {
|
||||
if skipPrune(b.Name(), s.c.BackupSkipBackendsFromPrune) {
|
||||
s.logger.Info(
|
||||
fmt.Sprintf("Skipping pruning for backend `%s`.", b.Name()),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
stats, err := b.Prune(deadline, s.c.BackupPruningPrefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.stats.Lock()
|
||||
s.stats.Storages[b.Name()] = StorageStats{
|
||||
Total: stats.Total,
|
||||
Pruned: stats.Pruned,
|
||||
}
|
||||
s.stats.Unlock()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := eg.Wait(); err != nil {
|
||||
return errwrap.Wrap(err, "error pruning backups")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// skipPrune returns true if the given backend name is contained in the
|
||||
// list of skipped backends.
|
||||
func skipPrune(name string, skippedBackends []string) bool {
|
||||
return slices.ContainsFunc(
|
||||
skippedBackends,
|
||||
func(b string) bool {
|
||||
return strings.EqualFold(b, name) // ignore case on both sides
|
||||
},
|
||||
)
|
||||
}
|
||||
111
cmd/backup/run_script.go
Normal file
111
cmd/backup/run_script.go
Normal file
@@ -0,0 +1,111 @@
|
||||
// Copyright 2024 - Offen Authors <hioffen@posteo.de>
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/offen/docker-volume-backup/internal/errwrap"
|
||||
)
|
||||
|
||||
// runScript instantiates a new script object and orchestrates a backup run.
|
||||
// To ensure it runs mutually exclusive a global file lock is acquired before
|
||||
// it starts running. Any panic within the script will be recovered and returned
|
||||
// as an error.
|
||||
func runScript(c *Config) (err error) {
|
||||
defer func() {
|
||||
if derr := recover(); derr != nil {
|
||||
asErr, ok := derr.(error)
|
||||
if ok {
|
||||
err = errwrap.Wrap(asErr, "unexpected panic running script")
|
||||
} else {
|
||||
err = errwrap.Wrap(nil, fmt.Sprintf("%v", derr))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
s := newScript(c)
|
||||
|
||||
unlock, lockErr := s.lock("/var/lock/dockervolumebackup.lock")
|
||||
if lockErr != nil {
|
||||
err = errwrap.Wrap(lockErr, "error acquiring file lock")
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if derr := unlock(); derr != nil {
|
||||
err = errors.Join(err, errwrap.Wrap(derr, "error releasing file lock"))
|
||||
}
|
||||
}()
|
||||
|
||||
unset, err := s.c.applyEnv()
|
||||
if err != nil {
|
||||
return errwrap.Wrap(err, "error applying env")
|
||||
}
|
||||
defer func() {
|
||||
if derr := unset(); derr != nil {
|
||||
err = errors.Join(err, errwrap.Wrap(derr, "error unsetting environment variables"))
|
||||
}
|
||||
}()
|
||||
|
||||
if initErr := s.init(); initErr != nil {
|
||||
err = errwrap.Wrap(initErr, "error instantiating script")
|
||||
return
|
||||
}
|
||||
|
||||
return func() (err error) {
|
||||
scriptErr := func() error {
|
||||
if err := s.withLabeledCommands(lifecyclePhaseArchive, func() (err error) {
|
||||
restartContainersAndServices, err := s.stopContainersAndServices()
|
||||
// The mechanism for restarting containers is not using hooks as it
|
||||
// should happen as soon as possible (i.e. before uploading backups or
|
||||
// similar).
|
||||
defer func() {
|
||||
if derr := restartContainersAndServices(); derr != nil {
|
||||
err = errors.Join(err, errwrap.Wrap(derr, "error restarting containers and services"))
|
||||
}
|
||||
}()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = s.createArchive()
|
||||
return
|
||||
})(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.withLabeledCommands(lifecyclePhaseProcess, s.encryptArchive)(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.withLabeledCommands(lifecyclePhaseCopy, s.copyArchive)(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.withLabeledCommands(lifecyclePhasePrune, s.pruneBackups)(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
if hookErr := s.runHooks(scriptErr); hookErr != nil {
|
||||
if scriptErr != nil {
|
||||
return errwrap.Wrap(
|
||||
nil,
|
||||
fmt.Sprintf(
|
||||
"error %v executing the script followed by %v calling the registered hooks",
|
||||
scriptErr,
|
||||
hookErr,
|
||||
),
|
||||
)
|
||||
}
|
||||
return errwrap.Wrap(
|
||||
hookErr,
|
||||
"the script ran successfully, but an error occurred calling the registered hooks",
|
||||
)
|
||||
}
|
||||
if scriptErr != nil {
|
||||
return errwrap.Wrap(scriptErr, "error running script")
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
}
|
||||
@@ -5,20 +5,14 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/offen/docker-volume-backup/internal/errwrap"
|
||||
"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"
|
||||
@@ -27,18 +21,10 @@ import (
|
||||
"github.com/offen/docker-volume-backup/internal/storage/ssh"
|
||||
"github.com/offen/docker-volume-backup/internal/storage/webdav"
|
||||
|
||||
openpgp "github.com/ProtonMail/go-crypto/openpgp/v2"
|
||||
"github.com/containrrr/shoutrrr"
|
||||
"github.com/containrrr/shoutrrr/pkg/router"
|
||||
"github.com/docker/docker/api/types"
|
||||
ctr "github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/swarm"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/leekchan/timeutil"
|
||||
"github.com/offen/envconfig"
|
||||
"github.com/otiai10/copy"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
// script holds all the stateful information required to orchestrate a
|
||||
@@ -64,10 +50,10 @@ type script struct {
|
||||
// remote resources like the Docker engine or remote storage locations. All
|
||||
// reading from env vars or other configuration sources is expected to happen
|
||||
// in this method.
|
||||
func newScript() (*script, error) {
|
||||
func newScript(c *Config) *script {
|
||||
stdOut, logBuffer := buffer(os.Stdout)
|
||||
s := &script{
|
||||
c: &Config{},
|
||||
return &script{
|
||||
c: c,
|
||||
logger: slog.New(slog.NewTextHandler(stdOut, nil)),
|
||||
stats: &Stats{
|
||||
StartTime: time.Now(),
|
||||
@@ -82,51 +68,27 @@ func newScript() (*script, error) {
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *script) init() error {
|
||||
s.registerHook(hookLevelPlumbing, func(error) error {
|
||||
s.stats.EndTime = time.Now()
|
||||
s.stats.TookTime = s.stats.EndTime.Sub(s.stats.StartTime)
|
||||
return nil
|
||||
})
|
||||
|
||||
envconfig.Lookup = func(key string) (string, bool) {
|
||||
value, okValue := os.LookupEnv(key)
|
||||
location, okFile := os.LookupEnv(key + "_FILE")
|
||||
|
||||
switch {
|
||||
case okValue && !okFile: // only value
|
||||
return value, true
|
||||
case !okValue && okFile: // only file
|
||||
contents, err := os.ReadFile(location)
|
||||
if err != nil {
|
||||
s.must(fmt.Errorf("newScript: failed to read %s! Error: %s", location, err))
|
||||
return "", false
|
||||
}
|
||||
return string(contents), true
|
||||
case okValue && okFile: // both
|
||||
s.must(fmt.Errorf("newScript: both %s and %s are set!", key, key+"_FILE"))
|
||||
return "", false
|
||||
default: // neither, ignore
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
if err := envconfig.Process("", s.c); err != nil {
|
||||
return nil, fmt.Errorf("newScript: failed to process configuration values: %w", err)
|
||||
}
|
||||
|
||||
s.file = path.Join("/tmp", s.c.BackupFilename)
|
||||
|
||||
tmplFileName, tErr := template.New("extension").Parse(s.file)
|
||||
if tErr != nil {
|
||||
return nil, fmt.Errorf("newScript: unable to parse backup file extension template: %w", tErr)
|
||||
return errwrap.Wrap(tErr, "unable to parse backup file extension template")
|
||||
}
|
||||
|
||||
var bf bytes.Buffer
|
||||
if tErr := tmplFileName.Execute(&bf, map[string]string{
|
||||
"Extension": fmt.Sprintf("tar.%s", s.c.BackupCompression),
|
||||
}); tErr != nil {
|
||||
return nil, fmt.Errorf("newScript: error executing backup file extension template: %w", tErr)
|
||||
return errwrap.Wrap(tErr, "error executing backup file extension template")
|
||||
}
|
||||
s.file = bf.String()
|
||||
|
||||
@@ -142,17 +104,21 @@ func newScript() (*script, error) {
|
||||
if !os.IsNotExist(err) || dockerHostSet {
|
||||
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("newScript: failed to create docker client")
|
||||
return errwrap.Wrap(err, "failed to create docker client")
|
||||
}
|
||||
s.cli = cli
|
||||
s.registerHook(hookLevelPlumbing, func(err error) error {
|
||||
if err := s.cli.Close(); err != nil {
|
||||
return errwrap.Wrap(err, "failed to close docker client")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
logFunc := func(logType storage.LogLevel, context string, msg string, params ...any) {
|
||||
switch logType {
|
||||
case storage.LogLevelWarning:
|
||||
s.logger.Warn(fmt.Sprintf(msg, params...), "storage", context)
|
||||
case storage.LogLevelError:
|
||||
s.logger.Error(fmt.Sprintf(msg, params...), "storage", context)
|
||||
default:
|
||||
s.logger.Info(fmt.Sprintf(msg, params...), "storage", context)
|
||||
}
|
||||
@@ -174,7 +140,7 @@ func newScript() (*script, error) {
|
||||
}
|
||||
s3Backend, err := s3.NewStorageBackend(s3Config, logFunc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("newScript: error creating s3 storage backend: %w", err)
|
||||
return errwrap.Wrap(err, "error creating s3 storage backend")
|
||||
}
|
||||
s.storages = append(s.storages, s3Backend)
|
||||
}
|
||||
@@ -189,7 +155,7 @@ func newScript() (*script, error) {
|
||||
}
|
||||
webdavBackend, err := webdav.NewStorageBackend(webDavConfig, logFunc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("newScript: error creating webdav storage backend: %w", err)
|
||||
return errwrap.Wrap(err, "error creating webdav storage backend")
|
||||
}
|
||||
s.storages = append(s.storages, webdavBackend)
|
||||
}
|
||||
@@ -206,7 +172,7 @@ func newScript() (*script, error) {
|
||||
}
|
||||
sshBackend, err := ssh.NewStorageBackend(sshConfig, logFunc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("newScript: error creating ssh storage backend: %w", err)
|
||||
return errwrap.Wrap(err, "error creating ssh storage backend")
|
||||
}
|
||||
s.storages = append(s.storages, sshBackend)
|
||||
}
|
||||
@@ -230,7 +196,7 @@ func newScript() (*script, error) {
|
||||
}
|
||||
azureBackend, err := azure.NewStorageBackend(azureConfig, logFunc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("newScript: error creating azure storage backend: %w", err)
|
||||
return errwrap.Wrap(err, "error creating azure storage backend")
|
||||
}
|
||||
s.storages = append(s.storages, azureBackend)
|
||||
}
|
||||
@@ -247,7 +213,7 @@ func newScript() (*script, error) {
|
||||
}
|
||||
dropboxBackend, err := dropbox.NewStorageBackend(dropboxConfig, logFunc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("newScript: error creating dropbox storage backend: %w", err)
|
||||
return errwrap.Wrap(err, "error creating dropbox storage backend")
|
||||
}
|
||||
s.storages = append(s.storages, dropboxBackend)
|
||||
}
|
||||
@@ -273,14 +239,14 @@ func newScript() (*script, error) {
|
||||
|
||||
hookLevel, ok := hookLevels[s.c.NotificationLevel]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("newScript: unknown NOTIFICATION_LEVEL %s", s.c.NotificationLevel)
|
||||
return errwrap.Wrap(nil, fmt.Sprintf("unknown NOTIFICATION_LEVEL %s", s.c.NotificationLevel))
|
||||
}
|
||||
s.hookLevel = hookLevel
|
||||
|
||||
if len(s.c.NotificationURLs) > 0 {
|
||||
sender, senderErr := shoutrrr.CreateSender(s.c.NotificationURLs...)
|
||||
if senderErr != nil {
|
||||
return nil, fmt.Errorf("newScript: error creating sender: %w", senderErr)
|
||||
return errwrap.Wrap(senderErr, "error creating sender")
|
||||
}
|
||||
s.sender = sender
|
||||
|
||||
@@ -288,13 +254,13 @@ func newScript() (*script, error) {
|
||||
tmpl.Funcs(templateHelpers)
|
||||
tmpl, err = tmpl.Parse(defaultNotifications)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("newScript: unable to parse default notifications templates: %w", err)
|
||||
return errwrap.Wrap(err, "unable to parse default notifications templates")
|
||||
}
|
||||
|
||||
if fi, err := os.Stat("/etc/dockervolumebackup/notifications.d"); err == nil && fi.IsDir() {
|
||||
tmpl, err = tmpl.ParseGlob("/etc/dockervolumebackup/notifications.d/*.*")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("newScript: unable to parse user defined notifications templates: %w", err)
|
||||
return errwrap.Wrap(err, "unable to parse user defined notifications templates")
|
||||
}
|
||||
}
|
||||
s.template = tmpl
|
||||
@@ -315,342 +281,5 @@ func newScript() (*script, error) {
|
||||
})
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// stopContainers stops all Docker containers that are marked as to being
|
||||
// stopped during the backup and returns a function that can be called to
|
||||
// restart everything that has been stopped.
|
||||
func (s *script) stopContainers() (func() error, error) {
|
||||
if s.cli == nil {
|
||||
return noop, nil
|
||||
}
|
||||
|
||||
allContainers, err := s.cli.ContainerList(context.Background(), types.ContainerListOptions{})
|
||||
if err != nil {
|
||||
return noop, fmt.Errorf("stopContainers: error querying for containers: %w", err)
|
||||
}
|
||||
|
||||
containerLabel := fmt.Sprintf(
|
||||
"docker-volume-backup.stop-during-backup=%s",
|
||||
s.c.BackupStopContainerLabel,
|
||||
)
|
||||
containersToStop, err := s.cli.ContainerList(context.Background(), types.ContainerListOptions{
|
||||
Filters: filters.NewArgs(filters.KeyValuePair{
|
||||
Key: "label",
|
||||
Value: containerLabel,
|
||||
}),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return noop, fmt.Errorf("stopContainers: error querying for containers to stop: %w", err)
|
||||
}
|
||||
|
||||
if len(containersToStop) == 0 {
|
||||
return noop, nil
|
||||
}
|
||||
|
||||
s.logger.Info(
|
||||
fmt.Sprintf(
|
||||
"Stopping %d container(s) labeled `%s` out of %d running container(s).",
|
||||
len(containersToStop),
|
||||
containerLabel,
|
||||
len(allContainers),
|
||||
),
|
||||
)
|
||||
|
||||
var stoppedContainers []types.Container
|
||||
var stopErrors []error
|
||||
for _, container := range containersToStop {
|
||||
if err := s.cli.ContainerStop(context.Background(), container.ID, ctr.StopOptions{}); err != nil {
|
||||
stopErrors = append(stopErrors, err)
|
||||
} else {
|
||||
stoppedContainers = append(stoppedContainers, container)
|
||||
}
|
||||
}
|
||||
|
||||
var stopError error
|
||||
if len(stopErrors) != 0 {
|
||||
stopError = fmt.Errorf(
|
||||
"stopContainers: %d error(s) stopping containers: %w",
|
||||
len(stopErrors),
|
||||
errors.Join(stopErrors...),
|
||||
)
|
||||
}
|
||||
|
||||
s.stats.Containers = ContainersStats{
|
||||
All: uint(len(allContainers)),
|
||||
ToStop: uint(len(containersToStop)),
|
||||
Stopped: uint(len(stoppedContainers)),
|
||||
}
|
||||
|
||||
return func() error {
|
||||
servicesRequiringUpdate := map[string]struct{}{}
|
||||
|
||||
var restartErrors []error
|
||||
for _, container := range stoppedContainers {
|
||||
if swarmServiceName, ok := container.Labels["com.docker.swarm.service.name"]; ok {
|
||||
servicesRequiringUpdate[swarmServiceName] = struct{}{}
|
||||
continue
|
||||
}
|
||||
if err := s.cli.ContainerStart(context.Background(), container.ID, types.ContainerStartOptions{}); err != nil {
|
||||
restartErrors = append(restartErrors, err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(servicesRequiringUpdate) != 0 {
|
||||
services, _ := s.cli.ServiceList(context.Background(), types.ServiceListOptions{})
|
||||
for serviceName := range servicesRequiringUpdate {
|
||||
var serviceMatch swarm.Service
|
||||
for _, service := range services {
|
||||
if service.Spec.Name == serviceName {
|
||||
serviceMatch = service
|
||||
break
|
||||
}
|
||||
}
|
||||
if serviceMatch.ID == "" {
|
||||
return fmt.Errorf("stopContainers: couldn't find service with name %s", serviceName)
|
||||
}
|
||||
serviceMatch.Spec.TaskTemplate.ForceUpdate += 1
|
||||
if _, err := s.cli.ServiceUpdate(
|
||||
context.Background(), serviceMatch.ID,
|
||||
serviceMatch.Version, serviceMatch.Spec, types.ServiceUpdateOptions{},
|
||||
); err != nil {
|
||||
restartErrors = append(restartErrors, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(restartErrors) != 0 {
|
||||
return fmt.Errorf(
|
||||
"stopContainers: %d error(s) restarting containers and services: %w",
|
||||
len(restartErrors),
|
||||
errors.Join(restartErrors...),
|
||||
)
|
||||
}
|
||||
s.logger.Info(
|
||||
fmt.Sprintf(
|
||||
"Restarted %d container(s) and the matching service(s).",
|
||||
len(stoppedContainers),
|
||||
),
|
||||
)
|
||||
return nil
|
||||
}, stopError
|
||||
}
|
||||
|
||||
// createArchive creates a tar archive of the configured backup location and
|
||||
// saves it to disk.
|
||||
func (s *script) createArchive() error {
|
||||
backupSources := s.c.BackupSources
|
||||
|
||||
if s.c.BackupFromSnapshot {
|
||||
s.logger.Warn(
|
||||
"Using BACKUP_FROM_SNAPSHOT has been deprecated and will be removed in the next major version.",
|
||||
)
|
||||
s.logger.Warn(
|
||||
"Please use `archive-pre` and `archive-post` commands to prepare your backup sources. Refer to the README for an upgrade guide.",
|
||||
)
|
||||
backupSources = filepath.Join("/tmp", s.c.BackupSources)
|
||||
// copy before compressing guard against a situation where backup folder's content are still growing.
|
||||
s.registerHook(hookLevelPlumbing, func(error) error {
|
||||
if err := remove(backupSources); err != nil {
|
||||
return fmt.Errorf("createArchive: error removing snapshot: %w", err)
|
||||
}
|
||||
s.logger.Info(
|
||||
fmt.Sprintf("Removed snapshot `%s`.", backupSources),
|
||||
)
|
||||
return nil
|
||||
})
|
||||
if err := copy.Copy(s.c.BackupSources, backupSources, copy.Options{
|
||||
PreserveTimes: true,
|
||||
PreserveOwner: true,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("createArchive: error creating snapshot: %w", err)
|
||||
}
|
||||
s.logger.Info(
|
||||
fmt.Sprintf("Created snapshot of `%s` at `%s`.", s.c.BackupSources, backupSources),
|
||||
)
|
||||
}
|
||||
|
||||
tarFile := s.file
|
||||
s.registerHook(hookLevelPlumbing, func(error) error {
|
||||
if err := remove(tarFile); err != nil {
|
||||
return fmt.Errorf("createArchive: error removing tar file: %w", err)
|
||||
}
|
||||
s.logger.Info(
|
||||
fmt.Sprintf("Removed tar file `%s`.", tarFile),
|
||||
)
|
||||
return nil
|
||||
})
|
||||
|
||||
backupPath, err := filepath.Abs(stripTrailingSlashes(backupSources))
|
||||
if err != nil {
|
||||
return fmt.Errorf("createArchive: error getting absolute path: %w", err)
|
||||
}
|
||||
|
||||
var filesEligibleForBackup []string
|
||||
if err := filepath.WalkDir(backupPath, func(path string, di fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if s.c.BackupExcludeRegexp.Re != nil && s.c.BackupExcludeRegexp.Re.MatchString(path) {
|
||||
return nil
|
||||
}
|
||||
filesEligibleForBackup = append(filesEligibleForBackup, path)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return fmt.Errorf("createArchive: error walking filesystem tree: %w", err)
|
||||
}
|
||||
|
||||
if err := createArchive(filesEligibleForBackup, backupSources, tarFile, s.c.BackupCompression.String(), s.c.GzipParallelism.Int()); err != nil {
|
||||
return fmt.Errorf("createArchive: error compressing backup folder: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info(
|
||||
fmt.Sprintf("Created backup of `%s` at `%s`.", backupSources, tarFile),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// encryptArchive encrypts the backup file using PGP and the configured passphrase.
|
||||
// In case no passphrase is given it returns early, leaving the backup file
|
||||
// untouched.
|
||||
func (s *script) encryptArchive() error {
|
||||
if s.c.GpgPassphrase == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
gpgFile := fmt.Sprintf("%s.gpg", s.file)
|
||||
s.registerHook(hookLevelPlumbing, func(error) error {
|
||||
if err := remove(gpgFile); err != nil {
|
||||
return fmt.Errorf("encryptArchive: error removing gpg file: %w", err)
|
||||
}
|
||||
s.logger.Info(
|
||||
fmt.Sprintf("Removed GPG file `%s`.", gpgFile),
|
||||
)
|
||||
return nil
|
||||
})
|
||||
|
||||
outFile, err := os.Create(gpgFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encryptArchive: error opening out file: %w", err)
|
||||
}
|
||||
defer outFile.Close()
|
||||
|
||||
_, name := path.Split(s.file)
|
||||
dst, err := openpgp.SymmetricallyEncrypt(outFile, []byte(s.c.GpgPassphrase), &openpgp.FileHints{
|
||||
FileName: name,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encryptArchive: error encrypting backup file: %w", err)
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
src, err := os.Open(s.file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encryptArchive: error opening backup file `%s`: %w", s.file, err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
return fmt.Errorf("encryptArchive: error writing ciphertext to file: %w", err)
|
||||
}
|
||||
|
||||
s.file = gpgFile
|
||||
s.logger.Info(
|
||||
fmt.Sprintf("Encrypted backup using given passphrase, saving as `%s`.", s.file),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyArchive makes sure the backup file is copied to both local and remote locations
|
||||
// as per the given configuration.
|
||||
func (s *script) copyArchive() error {
|
||||
_, name := path.Split(s.file)
|
||||
if stat, err := os.Stat(s.file); err != nil {
|
||||
return fmt.Errorf("copyArchive: unable to stat backup file: %w", err)
|
||||
} else {
|
||||
size := stat.Size()
|
||||
s.stats.BackupFile = BackupFileStats{
|
||||
Size: uint64(size),
|
||||
Name: name,
|
||||
FullPath: s.file,
|
||||
}
|
||||
}
|
||||
|
||||
eg := errgroup.Group{}
|
||||
for _, backend := range s.storages {
|
||||
b := backend
|
||||
eg.Go(func() error {
|
||||
return b.Copy(s.file)
|
||||
})
|
||||
}
|
||||
if err := eg.Wait(); err != nil {
|
||||
return fmt.Errorf("copyArchive: error copying archive: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// pruneBackups rotates away backups from local and remote storages using
|
||||
// the given configuration. In case the given configuration would delete all
|
||||
// backups, it does nothing instead and logs a warning.
|
||||
func (s *script) pruneBackups() error {
|
||||
if s.c.BackupRetentionDays < 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
deadline := time.Now().AddDate(0, 0, -int(s.c.BackupRetentionDays)).Add(s.c.BackupPruningLeeway)
|
||||
|
||||
eg := errgroup.Group{}
|
||||
for _, backend := range s.storages {
|
||||
b := backend
|
||||
eg.Go(func() error {
|
||||
if skipPrune(b.Name(), s.c.BackupSkipBackendsFromPrune) {
|
||||
s.logger.Info(
|
||||
fmt.Sprintf("Skipping pruning for backend `%s`.", b.Name()),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
stats, err := b.Prune(deadline, s.c.BackupPruningPrefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.stats.Lock()
|
||||
s.stats.Storages[b.Name()] = StorageStats{
|
||||
Total: stats.Total,
|
||||
Pruned: stats.Pruned,
|
||||
}
|
||||
s.stats.Unlock()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := eg.Wait(); err != nil {
|
||||
return fmt.Errorf("pruneBackups: error pruning backups: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// must exits the script run prematurely in case the given error
|
||||
// is non-nil.
|
||||
func (s *script) must(err error) {
|
||||
if err != nil {
|
||||
s.logger.Error(
|
||||
fmt.Sprintf("Fatal error running backup: %s", err),
|
||||
)
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// skipPrune returns true if the given backend name is contained in the
|
||||
// list of skipped backends.
|
||||
func skipPrune(name string, skippedBackends []string) bool {
|
||||
return slices.ContainsFunc(
|
||||
skippedBackends,
|
||||
func(b string) bool {
|
||||
return strings.EqualFold(b, name) // ignore case on both sides
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,15 @@ type ContainersStats struct {
|
||||
StopErrors uint
|
||||
}
|
||||
|
||||
// ServicesStats contains info about Swarm services that have been
|
||||
// operated upon
|
||||
type ServicesStats struct {
|
||||
All uint
|
||||
ToScaleDown uint
|
||||
ScaledDown uint
|
||||
ScaleDownErrors uint
|
||||
}
|
||||
|
||||
// BackupFileStats stats about the created backup file
|
||||
type BackupFileStats struct {
|
||||
Name string
|
||||
@@ -40,6 +49,7 @@ type Stats struct {
|
||||
LockedTime time.Duration
|
||||
LogOutput *bytes.Buffer
|
||||
Containers ContainersStats
|
||||
Services ServicesStats
|
||||
BackupFile BackupFileStats
|
||||
Storages map[string]StorageStats
|
||||
}
|
||||
|
||||
352
cmd/backup/stop_restart.go
Normal file
352
cmd/backup/stop_restart.go
Normal file
@@ -0,0 +1,352 @@
|
||||
// Copyright 2024 - Offen Authors <hioffen@posteo.de>
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/docker/cli/cli/command/service/progress"
|
||||
"github.com/docker/docker/api/types"
|
||||
ctr "github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/swarm"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/offen/docker-volume-backup/internal/errwrap"
|
||||
)
|
||||
|
||||
func scaleService(cli *client.Client, serviceID string, replicas uint64) ([]string, error) {
|
||||
service, _, err := cli.ServiceInspectWithRaw(context.Background(), serviceID, types.ServiceInspectOptions{})
|
||||
if err != nil {
|
||||
return nil, errwrap.Wrap(err, fmt.Sprintf("error inspecting service %s", serviceID))
|
||||
}
|
||||
serviceMode := &service.Spec.Mode
|
||||
switch {
|
||||
case serviceMode.Replicated != nil:
|
||||
serviceMode.Replicated.Replicas = &replicas
|
||||
default:
|
||||
return nil, errwrap.Wrap(nil, fmt.Sprintf("service to be scaled %s has to be in replicated mode", service.Spec.Name))
|
||||
}
|
||||
|
||||
response, err := cli.ServiceUpdate(context.Background(), service.ID, service.Version, service.Spec, types.ServiceUpdateOptions{})
|
||||
if err != nil {
|
||||
return nil, errwrap.Wrap(err, "error updating service")
|
||||
}
|
||||
|
||||
discardWriter := &noopWriteCloser{io.Discard}
|
||||
if err := progress.ServiceProgress(context.Background(), cli, service.ID, discardWriter); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response.Warnings, nil
|
||||
}
|
||||
|
||||
func awaitContainerCountForService(cli *client.Client, serviceID string, count int, timeoutAfter time.Duration) error {
|
||||
poll := time.NewTicker(time.Second)
|
||||
timeout := time.NewTimer(timeoutAfter)
|
||||
defer timeout.Stop()
|
||||
defer poll.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-timeout.C:
|
||||
return errwrap.Wrap(
|
||||
nil,
|
||||
fmt.Sprintf(
|
||||
"timed out after waiting %s for service %s to reach desired container count of %d",
|
||||
timeoutAfter,
|
||||
serviceID,
|
||||
count,
|
||||
),
|
||||
)
|
||||
case <-poll.C:
|
||||
containers, err := cli.ContainerList(context.Background(), types.ContainerListOptions{
|
||||
Filters: filters.NewArgs(filters.KeyValuePair{
|
||||
Key: "label",
|
||||
Value: fmt.Sprintf("com.docker.swarm.service.id=%s", serviceID),
|
||||
}),
|
||||
})
|
||||
if err != nil {
|
||||
return errwrap.Wrap(err, "error listing containers")
|
||||
}
|
||||
if len(containers) == count {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// stopContainersAndServices stops all Docker containers that are marked as to being
|
||||
// stopped during the backup and returns a function that can be called to
|
||||
// restart everything that has been stopped.
|
||||
func (s *script) stopContainersAndServices() (func() error, error) {
|
||||
if s.cli == nil {
|
||||
return noop, nil
|
||||
}
|
||||
|
||||
dockerInfo, err := s.cli.Info(context.Background())
|
||||
if err != nil {
|
||||
return noop, errwrap.Wrap(err, "error getting docker info")
|
||||
}
|
||||
isDockerSwarm := dockerInfo.Swarm.LocalNodeState != "inactive"
|
||||
|
||||
labelValue := s.c.BackupStopDuringBackupLabel
|
||||
if s.c.BackupStopContainerLabel != "" {
|
||||
s.logger.Warn(
|
||||
"Using BACKUP_STOP_CONTAINER_LABEL has been deprecated and will be removed in the next major version.",
|
||||
)
|
||||
s.logger.Warn(
|
||||
"Please use BACKUP_STOP_DURING_BACKUP_LABEL instead. Refer to the docs for an upgrade guide.",
|
||||
)
|
||||
if _, ok := os.LookupEnv("BACKUP_STOP_DURING_BACKUP_LABEL"); ok {
|
||||
return noop, errwrap.Wrap(nil, "both BACKUP_STOP_DURING_BACKUP_LABEL and BACKUP_STOP_CONTAINER_LABEL have been set, cannot continue")
|
||||
}
|
||||
labelValue = s.c.BackupStopContainerLabel
|
||||
}
|
||||
|
||||
filterMatchLabel := fmt.Sprintf(
|
||||
"docker-volume-backup.stop-during-backup=%s",
|
||||
labelValue,
|
||||
)
|
||||
|
||||
allContainers, err := s.cli.ContainerList(context.Background(), types.ContainerListOptions{})
|
||||
if err != nil {
|
||||
return noop, errwrap.Wrap(err, "error querying for containers")
|
||||
}
|
||||
containersToStop, err := s.cli.ContainerList(context.Background(), types.ContainerListOptions{
|
||||
Filters: filters.NewArgs(filters.KeyValuePair{
|
||||
Key: "label",
|
||||
Value: filterMatchLabel,
|
||||
}),
|
||||
})
|
||||
if err != nil {
|
||||
return noop, errwrap.Wrap(err, "error querying for containers to stop")
|
||||
}
|
||||
|
||||
var allServices []swarm.Service
|
||||
var servicesToScaleDown []handledSwarmService
|
||||
if isDockerSwarm {
|
||||
allServices, err = s.cli.ServiceList(context.Background(), types.ServiceListOptions{})
|
||||
if err != nil {
|
||||
return noop, errwrap.Wrap(err, "error querying for services")
|
||||
}
|
||||
matchingServices, err := s.cli.ServiceList(context.Background(), types.ServiceListOptions{
|
||||
Filters: filters.NewArgs(filters.KeyValuePair{
|
||||
Key: "label",
|
||||
Value: filterMatchLabel,
|
||||
}),
|
||||
Status: true,
|
||||
})
|
||||
for _, s := range matchingServices {
|
||||
servicesToScaleDown = append(servicesToScaleDown, handledSwarmService{
|
||||
serviceID: s.ID,
|
||||
initialReplicaCount: *s.Spec.Mode.Replicated.Replicas,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
return noop, errwrap.Wrap(err, "error querying for services to scale down")
|
||||
}
|
||||
}
|
||||
|
||||
if len(containersToStop) == 0 && len(servicesToScaleDown) == 0 {
|
||||
return noop, nil
|
||||
}
|
||||
|
||||
if isDockerSwarm {
|
||||
for _, container := range containersToStop {
|
||||
if swarmServiceID, ok := container.Labels["com.docker.swarm.service.id"]; ok {
|
||||
parentService, _, err := s.cli.ServiceInspectWithRaw(context.Background(), swarmServiceID, types.ServiceInspectOptions{})
|
||||
if err != nil {
|
||||
return noop, errwrap.Wrap(err, fmt.Sprintf("error querying for parent service with ID %s", swarmServiceID))
|
||||
}
|
||||
for label := range parentService.Spec.Labels {
|
||||
if label == "docker-volume-backup.stop-during-backup" {
|
||||
return noop, errwrap.Wrap(
|
||||
nil,
|
||||
fmt.Sprintf(
|
||||
"container %s is labeled to stop but has parent service %s which is also labeled, cannot continue",
|
||||
container.Names[0],
|
||||
parentService.Spec.Name,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.logger.Info(
|
||||
fmt.Sprintf(
|
||||
"Stopping %d out of %d running container(s) as they were labeled %s.",
|
||||
len(containersToStop),
|
||||
len(allContainers),
|
||||
filterMatchLabel,
|
||||
),
|
||||
)
|
||||
if isDockerSwarm {
|
||||
s.logger.Info(
|
||||
fmt.Sprintf(
|
||||
"Scaling down %d out of %d active service(s) as they were labeled %s.",
|
||||
len(servicesToScaleDown),
|
||||
len(allServices),
|
||||
filterMatchLabel,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
var stoppedContainers []types.Container
|
||||
var stopErrors []error
|
||||
for _, container := range containersToStop {
|
||||
if err := s.cli.ContainerStop(context.Background(), container.ID, ctr.StopOptions{}); err != nil {
|
||||
stopErrors = append(stopErrors, err)
|
||||
} else {
|
||||
stoppedContainers = append(stoppedContainers, container)
|
||||
}
|
||||
}
|
||||
|
||||
var scaledDownServices []handledSwarmService
|
||||
var scaleDownErrors concurrentSlice[error]
|
||||
if isDockerSwarm {
|
||||
wg := sync.WaitGroup{}
|
||||
for _, svc := range servicesToScaleDown {
|
||||
wg.Add(1)
|
||||
go func(svc handledSwarmService) {
|
||||
defer wg.Done()
|
||||
warnings, err := scaleService(s.cli, svc.serviceID, 0)
|
||||
if err != nil {
|
||||
scaleDownErrors.append(err)
|
||||
return
|
||||
}
|
||||
scaledDownServices = append(scaledDownServices, svc)
|
||||
for _, warning := range warnings {
|
||||
s.logger.Warn(
|
||||
fmt.Sprintf("The Docker API returned a warning when scaling down service %s: %s", svc.serviceID, warning),
|
||||
)
|
||||
}
|
||||
// progress.ServiceProgress returns too early, so we need to manually check
|
||||
// whether all containers belonging to the service have actually been removed
|
||||
if err := awaitContainerCountForService(s.cli, svc.serviceID, 0, s.c.BackupStopServiceTimeout); err != nil {
|
||||
scaleDownErrors.append(err)
|
||||
}
|
||||
}(svc)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
s.stats.Containers = ContainersStats{
|
||||
All: uint(len(allContainers)),
|
||||
ToStop: uint(len(containersToStop)),
|
||||
Stopped: uint(len(stoppedContainers)),
|
||||
StopErrors: uint(len(stopErrors)),
|
||||
}
|
||||
|
||||
s.stats.Services = ServicesStats{
|
||||
All: uint(len(allServices)),
|
||||
ToScaleDown: uint(len(servicesToScaleDown)),
|
||||
ScaledDown: uint(len(scaledDownServices)),
|
||||
ScaleDownErrors: uint(len(scaleDownErrors.value())),
|
||||
}
|
||||
|
||||
var initialErr error
|
||||
allErrors := append(stopErrors, scaleDownErrors.value()...)
|
||||
if len(allErrors) != 0 {
|
||||
initialErr = errwrap.Wrap(
|
||||
errors.Join(allErrors...),
|
||||
fmt.Sprintf(
|
||||
"%d error(s) stopping containers",
|
||||
len(allErrors),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return func() error {
|
||||
var restartErrors []error
|
||||
matchedServices := map[string]bool{}
|
||||
for _, container := range stoppedContainers {
|
||||
if swarmServiceID, ok := container.Labels["com.docker.swarm.service.id"]; ok && isDockerSwarm {
|
||||
if _, ok := matchedServices[swarmServiceID]; ok {
|
||||
continue
|
||||
}
|
||||
matchedServices[swarmServiceID] = true
|
||||
// in case a container was part of a swarm service, the service requires to
|
||||
// be force updated instead of restarting the container as it would otherwise
|
||||
// remain in a "completed" state
|
||||
service, _, err := s.cli.ServiceInspectWithRaw(context.Background(), swarmServiceID, types.ServiceInspectOptions{})
|
||||
if err != nil {
|
||||
restartErrors = append(
|
||||
restartErrors,
|
||||
errwrap.Wrap(err, "error looking up parent service"),
|
||||
)
|
||||
continue
|
||||
}
|
||||
service.Spec.TaskTemplate.ForceUpdate += 1
|
||||
if _, err := s.cli.ServiceUpdate(
|
||||
context.Background(), service.ID,
|
||||
service.Version, service.Spec, types.ServiceUpdateOptions{},
|
||||
); err != nil {
|
||||
restartErrors = append(restartErrors, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if err := s.cli.ContainerStart(context.Background(), container.ID, types.ContainerStartOptions{}); err != nil {
|
||||
restartErrors = append(restartErrors, err)
|
||||
}
|
||||
}
|
||||
|
||||
var scaleUpErrors concurrentSlice[error]
|
||||
if isDockerSwarm {
|
||||
wg := &sync.WaitGroup{}
|
||||
for _, svc := range servicesToScaleDown {
|
||||
wg.Add(1)
|
||||
go func(svc handledSwarmService) {
|
||||
defer wg.Done()
|
||||
warnings, err := scaleService(s.cli, svc.serviceID, svc.initialReplicaCount)
|
||||
if err != nil {
|
||||
scaleDownErrors.append(err)
|
||||
return
|
||||
}
|
||||
for _, warning := range warnings {
|
||||
s.logger.Warn(
|
||||
fmt.Sprintf("The Docker API returned a warning when scaling up service %s: %s", svc.serviceID, warning),
|
||||
)
|
||||
}
|
||||
}(svc)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
allErrors := append(restartErrors, scaleUpErrors.value()...)
|
||||
if len(allErrors) != 0 {
|
||||
return errwrap.Wrap(
|
||||
errors.Join(allErrors...),
|
||||
fmt.Sprintf(
|
||||
"%d error(s) restarting containers and services",
|
||||
len(allErrors),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
s.logger.Info(
|
||||
fmt.Sprintf(
|
||||
"Restarted %d container(s).",
|
||||
len(stoppedContainers),
|
||||
),
|
||||
)
|
||||
if isDockerSwarm {
|
||||
s.logger.Info(
|
||||
fmt.Sprintf(
|
||||
"Scaled %d service(s) back up.",
|
||||
len(scaledDownServices),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, initialErr
|
||||
}
|
||||
3
cmd/backup/testdata/braces.env
vendored
Normal file
3
cmd/backup/testdata/braces.env
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
FOO=${bar:-qux}
|
||||
BAR=xxx
|
||||
BAZ=$NOPE
|
||||
2
cmd/backup/testdata/default.env
vendored
Normal file
2
cmd/backup/testdata/default.env
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
FOO=bar
|
||||
BAZ=qux
|
||||
4
cmd/backup/testdata/expansion.env
vendored
Normal file
4
cmd/backup/testdata/expansion.env
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
BAR=xxx
|
||||
FOO=${BAR}
|
||||
BAZ=$BAR
|
||||
QUX=${QUX}
|
||||
@@ -8,6 +8,11 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/offen/docker-volume-backup/internal/errwrap"
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
var noop = func() error { return nil }
|
||||
@@ -19,7 +24,7 @@ func remove(location string) error {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("remove: error checking for existence of `%s`: %w", location, err)
|
||||
return errwrap.Wrap(err, fmt.Sprintf("error checking for existence of `%s`", location))
|
||||
}
|
||||
if fi.IsDir() {
|
||||
err = os.RemoveAll(location)
|
||||
@@ -27,7 +32,7 @@ func remove(location string) error {
|
||||
err = os.Remove(location)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove: error removing `%s`: %w", location, err)
|
||||
return errwrap.Wrap(err, fmt.Sprintf("error removing `%s", location))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -46,7 +51,54 @@ type bufferingWriter struct {
|
||||
|
||||
func (b *bufferingWriter) Write(p []byte) (n int, err error) {
|
||||
if n, err := b.buf.Write(p); err != nil {
|
||||
return n, fmt.Errorf("(*bufferingWriter).Write: error writing to buffer: %w", err)
|
||||
return n, errwrap.Wrap(err, "error writing to buffer")
|
||||
}
|
||||
return b.writer.Write(p)
|
||||
}
|
||||
|
||||
type noopWriteCloser struct {
|
||||
io.Writer
|
||||
}
|
||||
|
||||
func (noopWriteCloser) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type handledSwarmService struct {
|
||||
serviceID string
|
||||
initialReplicaCount uint64
|
||||
}
|
||||
|
||||
type concurrentSlice[T any] struct {
|
||||
val []T
|
||||
sync.Mutex
|
||||
}
|
||||
|
||||
func (c *concurrentSlice[T]) append(v T) {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
c.val = append(c.val, v)
|
||||
}
|
||||
|
||||
func (c *concurrentSlice[T]) value() []T {
|
||||
return c.val
|
||||
}
|
||||
|
||||
// checkCronSchedule detects whether the given cron expression will actually
|
||||
// ever be executed or not.
|
||||
func checkCronSchedule(expression string) (ok bool) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
ok = false
|
||||
}
|
||||
}()
|
||||
sched, err := cron.ParseStandard(expression)
|
||||
if err != nil {
|
||||
ok = false
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
sched.Next(now) // panics when the cron would never run
|
||||
ok = true
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
title: Replace deprecated BACKUP_FROM_SNAPSHOT usage
|
||||
layout: default
|
||||
parent: How Tos
|
||||
nav_order: 16
|
||||
nav_order: 17
|
||||
---
|
||||
|
||||
# Replace deprecated `BACKUP_FROM_SNAPSHOT` usage
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
title: Replace deprecated BACKUP_STOP_CONTAINER_LABEL setting
|
||||
layout: default
|
||||
parent: How Tos
|
||||
nav_order: 20
|
||||
---
|
||||
|
||||
# Replace deprecated `BACKUP_STOP_CONTAINER_LABEL` setting
|
||||
|
||||
Version `v2.36.0` deprecated the `BACKUP_STOP_CONTAINER_LABEL` setting and renamed it `BACKUP_STOP_DURING_BACKUP_LABEL` which is supposed to signal that this will stop both containers _and_ services.
|
||||
Migrating is done by renaming the key for your custom value:
|
||||
|
||||
```diff
|
||||
env:
|
||||
- BACKUP_STOP_CONTAINER_LABEL: database
|
||||
+ BACKUP_STOP_DURING_BACKUP_LABEL: database
|
||||
```
|
||||
|
||||
The old key will stay supported until the next major version, but logs a warning each time a backup is taken.
|
||||
@@ -2,7 +2,7 @@
|
||||
title: Replace deprecated exec-pre and exec-post labels
|
||||
layout: default
|
||||
parent: How Tos
|
||||
nav_order: 17
|
||||
nav_order: 18
|
||||
---
|
||||
|
||||
# Replace deprecated `exec-pre` and `exec-post` labels
|
||||
|
||||
@@ -76,7 +76,7 @@ Configuration, data about the backup run and helper functions will be passed to
|
||||
|
||||
Here is a list of all data passed to the template:
|
||||
|
||||
* `Config`: this object holds the configuration that has been passed to the script. The field names are the name of the recognized environment variables converted in PascalCase. (e.g. `BACKUP_STOP_CONTAINER_LABEL` becomes `BackupStopContainerLabel`)
|
||||
* `Config`: this object holds the configuration that has been passed to the script. The field names are the name of the recognized environment variables converted in PascalCase. (e.g. `BACKUP_STOP_DURING_BACKUP_LABEL` becomes `BackupStopDuringBackupLabel`)
|
||||
* `Error`: the error that made the backup fail. Only available in the `title_failure` and `body_failure` templates
|
||||
* `Stats`: objects that holds stats regarding script execution. In case of an unsuccessful run, some information may not be available.
|
||||
* `StartTime`: time when the script started execution
|
||||
@@ -89,6 +89,11 @@ Here is a list of all data passed to the template:
|
||||
* `ToStop`: number of containers matched by the stop rule
|
||||
* `Stopped`: number of containers successfully stopped
|
||||
* `StopErrors`: number of containers that were unable to be stopped (equal to `ToStop - Stopped`)
|
||||
* `Services`: object containing stats about the docker services (only populated when Docker is running in Swarm mode)
|
||||
* `All`: total number of services
|
||||
* `ToScaleDown`: number of containers matched by the scale down rule
|
||||
* `ScaledDwon`: number of containers successfully scaled down
|
||||
* `ScaleDownErrors`: number of containers that were unable to be stopped (equal to `ToScaleDown - ScaledDowm`)
|
||||
* `BackupFile`: object containing information about the backup file
|
||||
* `Name`: name of the backup file (e.g. `backup-2022-02-11T01-00-00.tar.gz`)
|
||||
* `FullPath`: full path of the backup file (e.g. `/archive/backup-2022-02-11T01-00-00.tar.gz`)
|
||||
|
||||
@@ -7,11 +7,14 @@ nav_order: 1
|
||||
|
||||
# Stop containers during backup
|
||||
|
||||
{: .note }
|
||||
In case you are running Docker in Swarm mode, [dedicated documentation](./use-with-docker-swarm.html) on service and container restart applies.
|
||||
|
||||
In many cases, it will be desirable to stop the services that are consuming the volume you want to backup in order to ensure data integrity.
|
||||
This image can automatically stop and restart containers and services.
|
||||
By default, any container that is labeled `docker-volume-backup.stop-during-backup=true` will be stopped before the backup is being taken and restarted once it has finished.
|
||||
|
||||
In case you need more fine grained control about which containers should be stopped (e.g. when backing up multiple volumes on different schedules), you can set the `BACKUP_STOP_CONTAINER_LABEL` environment variable and then use the same value for labeling:
|
||||
In case you need more fine grained control about which containers should be stopped (e.g. when backing up multiple volumes on different schedules), you can set the `BACKUP_STOP_DURING_BACKUP_LABEL` environment variable and then use the same value for labeling:
|
||||
|
||||
```yml
|
||||
version: '3'
|
||||
@@ -25,7 +28,7 @@ services:
|
||||
backup:
|
||||
image: offen/docker-volume-backup:v2
|
||||
environment:
|
||||
BACKUP_STOP_CONTAINER_LABEL: service1
|
||||
BACKUP_STOP_DURING_BACKUP_LABEL: service1
|
||||
volumes:
|
||||
- data:/backup/my-app-backup:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
title: Update deprecated email configuration
|
||||
layout: default
|
||||
parent: How Tos
|
||||
nav_order: 18
|
||||
nav_order: 19
|
||||
---
|
||||
|
||||
# Update deprecated email configuration
|
||||
|
||||
36
docs/how-tos/use-as-non-root.md
Normal file
36
docs/how-tos/use-as-non-root.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: Use the image as a non-root user
|
||||
layout: default
|
||||
parent: How Tos
|
||||
nav_order: 16
|
||||
---
|
||||
|
||||
# Use the image as a non-root user
|
||||
|
||||
{: .important }
|
||||
Running as a non-root user limits interaction with the Docker Daemon.
|
||||
If you want to stop and restart containers and services during backup, and the host's Docker daemon is running as root, you will also need to run this tool as root.
|
||||
|
||||
By default, this image executes backups using the `root` user.
|
||||
In case you prefer to use a different user, you can use Docker's [`user`](https://docs.docker.com/engine/reference/run/#user) option, passing the user and group id:
|
||||
|
||||
```console
|
||||
docker run --rm \
|
||||
-v data:/backup/data \
|
||||
--env AWS_ACCESS_KEY_ID="<xxx>" \
|
||||
--env AWS_SECRET_ACCESS_KEY="<xxx>" \
|
||||
--env AWS_S3_BUCKET_NAME="<xxx>" \
|
||||
--entrypoint backup \
|
||||
--user 1000:1000 \
|
||||
offen/docker-volume-backup:v2
|
||||
```
|
||||
|
||||
or in a compose file:
|
||||
|
||||
```yml
|
||||
services:
|
||||
backup:
|
||||
image: offen/docker-volume-backup:v2
|
||||
user: 1000:1000
|
||||
# further configuration omitted ...
|
||||
```
|
||||
@@ -13,5 +13,33 @@ If you are interfacing with Docker via TCP, set `DOCKER_HOST` to the correct URL
|
||||
DOCKER_HOST=tcp://docker_socket_proxy:2375
|
||||
```
|
||||
|
||||
In case you are using a socket proxy, it must support `GET` and `POST` requests to the `/containers` endpoint. If you are using Docker Swarm, it must also support the `/services` endpoint. If you are using pre/post backup commands, it must also support the `/exec` endpoint.
|
||||
If you do this as you seek to restrict access to the Docker socket, this tool is potentially calling the following Docker APIs:
|
||||
|
||||
| API | When |
|
||||
|-|-|
|
||||
| `Info` | always |
|
||||
| `ContainerExecCreate` | running commands from `exec-labels` |
|
||||
| `ContainerExecAttach` | running commands from `exec-labels` |
|
||||
| `ContainerExecInspect` | running commands from `exec-labels` |
|
||||
| `ContainerList` | always |
|
||||
`ServiceList` | Docker engine is running in Swarm mode |
|
||||
| `ServiceInspect` | Docker engine is running in Swarm mode |
|
||||
| `ServiceUpdate` | Docker engine is running in Swarm mode and `stop-during-backup` is used |
|
||||
| `ConatinerStop` | `stop-during-backup` labels are applied to containers |
|
||||
| `ContainerStart` | `stop-during-backup` labels are applied to container |
|
||||
|
||||
---
|
||||
|
||||
In case you are using [`docker-socket-proxy`][proxy], this means following permissions are required:
|
||||
|
||||
| Permission | When |
|
||||
|-|-|
|
||||
| INFO | always required |
|
||||
| CONTAINERS | always required |
|
||||
| POST | required when using `stop-during-backup` or `exec` labels |
|
||||
| EXEC | required when using `exec`-labeled commands |
|
||||
| SERVICES | required when Docker Engine is running in Swarm mode |
|
||||
| NODES | required when labeling services `stop-during-backup` |
|
||||
| TASKS | required when labeling services `stop-during-backup` |
|
||||
|
||||
[proxy]: https://github.com/Tecnativa/docker-socket-proxy
|
||||
|
||||
@@ -7,12 +7,66 @@ nav_order: 13
|
||||
|
||||
# Use with Docker Swarm
|
||||
|
||||
By default, Docker Swarm will restart stopped containers automatically, even when manually stopped.
|
||||
If you plan to have your containers / services stopped during backup, this means you need to apply the `on-failure` restart policy to your service's definitions.
|
||||
A restart policy of `always` is not compatible with this tool.
|
||||
{: .note }
|
||||
The mechanisms described in this page __do only apply when Docker is running in [Swarm mode][swarm]__.
|
||||
|
||||
[swarm]: https://docs.docker.com/engine/swarm/
|
||||
|
||||
## Stopping containers during backup
|
||||
|
||||
Stopping and restarting containers during backup creation when running Docker in Swarm mode is supported in two ways.
|
||||
|
||||
{: .important }
|
||||
Make sure you label your services and containers using only one of the describe approaches.
|
||||
In case the script encounters a container that is labeled and has a parent service that is also labeled, it will exit early.
|
||||
|
||||
### Scaling services down to zero before scaling back up
|
||||
|
||||
When labeling a service in the `deploy` section, the following strategy for stopping and restarting will be used:
|
||||
|
||||
- The service is scaled down to zero replicas
|
||||
- The backup is created
|
||||
- The service is scaled back up to the previous number of replicas
|
||||
|
||||
{: .note }
|
||||
This approach will only work for services that are deployed in __replicated mode__.
|
||||
|
||||
Such a service definition could look like:
|
||||
|
||||
```yml
|
||||
services:
|
||||
app:
|
||||
image: myorg/myimage:latest
|
||||
deploy:
|
||||
labels:
|
||||
- docker-volume-backup.stop-during-backup=true
|
||||
replicas: 2
|
||||
```
|
||||
|
||||
### Stopping the containers
|
||||
|
||||
This approach bypasses the services and stops containers directly, creates the backup and restarts the containers again.
|
||||
As Docker Swarm would usually try to instantly restart containers that are manually stopped, this approach only works when using the `on-failure` restart policy.
|
||||
A restart policy of `always` is not compatible with this approach.
|
||||
|
||||
Such a service definition could look like:
|
||||
|
||||
```yml
|
||||
services:
|
||||
app:
|
||||
image: myapp/myimage:latest
|
||||
labels:
|
||||
- docker-volume-backup.stop-during-backup=true
|
||||
deploy:
|
||||
replicas: 2
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Memory limit considerations
|
||||
|
||||
When running in Swarm mode, it's also advised to set a hard memory limit on your service (~25MB should be enough in most cases, but if you backup large files above half a gigabyte or similar, you might have to raise this in case the backup exits with `Killed`):
|
||||
|
||||
```yml
|
||||
|
||||
@@ -352,7 +352,7 @@ services:
|
||||
AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE
|
||||
AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
|
||||
# Label the container using the `data_1` volume as `docker-volume-backup.stop-during-backup=service1`
|
||||
BACKUP_STOP_CONTAINER_LABEL: service1
|
||||
BACKUP_STOP_DURING_BACKUP_LABEL: service1
|
||||
volumes:
|
||||
- data_1:/backup/data-1-backup:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
@@ -362,7 +362,7 @@ services:
|
||||
<<: *backup_environment
|
||||
# Label the container using the `data_2` volume as `docker-volume-backup.stop-during-backup=service2`
|
||||
BACKUP_CRON_EXPRESSION: "0 3 * * *"
|
||||
BACKUP_STOP_CONTAINER_LABEL: service2
|
||||
BACKUP_STOP_DURING_BACKUP_LABEL: service2
|
||||
volumes:
|
||||
- data_2:/backup/data-2-backup:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
@@ -371,3 +371,24 @@ volumes:
|
||||
data_1:
|
||||
data_2:
|
||||
```
|
||||
|
||||
## Running as a non-root user
|
||||
|
||||
```yml
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
# ... define other services using the `data` volume here
|
||||
backup:
|
||||
image: offen/docker-volume-backup:v2
|
||||
user: 1000:1000
|
||||
environment:
|
||||
AWS_S3_BUCKET_NAME: backup-bucket
|
||||
AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE
|
||||
AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
|
||||
volumes:
|
||||
- data:/backup/my-app-backup:ro
|
||||
|
||||
volumes:
|
||||
data:
|
||||
```
|
||||
|
||||
@@ -23,9 +23,22 @@ You can populate below template according to your requirements and use it as you
|
||||
```
|
||||
########### BACKUP SCHEDULE
|
||||
|
||||
# Backups run on the given cron schedule in `busybox` flavor. If no
|
||||
# value is set, `@daily` will be used. If you do not want the cron
|
||||
# to ever run, use `0 0 5 31 2 ?`.
|
||||
|
||||
# A cron expression represents a set of times, using 5 or 6 space-separated fields.
|
||||
#
|
||||
# Field name | Mandatory? | Allowed values | Allowed special characters
|
||||
# ---------- | ---------- | -------------- | --------------------------
|
||||
# Seconds | No | 0-59 | * / , -
|
||||
# Minutes | Yes | 0-59 | * / , -
|
||||
# Hours | Yes | 0-23 | * / , -
|
||||
# Day of month | Yes | 1-31 | * / , - ?
|
||||
# Month | Yes | 1-12 or JAN-DEC | * / , -
|
||||
# Day of week | Yes | 0-6 or SUN-SAT | * / , - ?
|
||||
#
|
||||
# Month and Day-of-week field values are case insensitive.
|
||||
# "SUN", "Sun", and "sun" are equally accepted.
|
||||
# If no value is set, `@daily` will be used.
|
||||
# If you do not want the cron to ever run, use `0 0 5 31 2 ?`.
|
||||
|
||||
# BACKUP_CRON_EXPRESSION="0 2 * * *"
|
||||
|
||||
@@ -316,15 +329,22 @@ You can populate below template according to your requirements and use it as you
|
||||
|
||||
# GPG_PASSPHRASE="<xxx>"
|
||||
|
||||
########### STOPPING CONTAINERS DURING BACKUP
|
||||
########### STOPPING CONTAINERS AND SERVICES DURING BACKUP
|
||||
|
||||
# Containers can be stopped by applying a
|
||||
# `docker-volume-backup.stop-during-backup` label. By default, all containers
|
||||
# that are labeled with `true` will be stopped. If you need more fine grained
|
||||
# control (e.g. when running multiple containers based on this image), you can
|
||||
# override this default by specifying a different value here.
|
||||
# Containers or services can be stopped by applying a
|
||||
# `docker-volume-backup.stop-during-backup` label. By default, all containers and
|
||||
# services that are labeled with `true` will be stopped. If you need more fine
|
||||
# grained control (e.g. when running multiple containers based on this image),
|
||||
# you can override this default by specifying a different value here.
|
||||
# BACKUP_STOP_DURING_BACKUP_LABEL="service1"
|
||||
|
||||
# BACKUP_STOP_CONTAINER_LABEL="service1"
|
||||
# When trying to scale down Docker Swarm services, give up after
|
||||
# the specified amount of time in case the service has not converged yet.
|
||||
# In case you need to adjust this timeout, supply a duration
|
||||
# value as per https://pkg.go.dev/time#ParseDuration to `BACKUP_STOP_SERVICE_TIMEOUT`.
|
||||
# Defaults to 5 minutes.
|
||||
|
||||
# BACKUP_STOP_SERVICE_TIMEOUT="5m"
|
||||
|
||||
########### EXECUTING COMMANDS IN CONTAINERS PRE/POST BACKUP
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Copyright 2021 - Offen Authors <hioffen@posteo.de>
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
set -e
|
||||
|
||||
if [ ! -d "/etc/dockervolumebackup/conf.d" ]; then
|
||||
BACKUP_CRON_EXPRESSION="${BACKUP_CRON_EXPRESSION:-@daily}"
|
||||
|
||||
echo "Installing cron.d entry with expression $BACKUP_CRON_EXPRESSION."
|
||||
echo "$BACKUP_CRON_EXPRESSION backup 2>&1" | crontab -
|
||||
else
|
||||
echo "/etc/dockervolumebackup/conf.d was found, using configuration files from this directory."
|
||||
|
||||
crontab -r && crontab /dev/null
|
||||
for file in /etc/dockervolumebackup/conf.d/*; do
|
||||
source $file
|
||||
BACKUP_CRON_EXPRESSION="${BACKUP_CRON_EXPRESSION:-@daily}"
|
||||
echo "Appending cron.d entry with expression $BACKUP_CRON_EXPRESSION and configuration file $file"
|
||||
(crontab -l; echo "$BACKUP_CRON_EXPRESSION /bin/sh -c 'set -a; source $file; set +a && backup' 2>&1") | crontab -
|
||||
done
|
||||
fi
|
||||
|
||||
echo "Starting cron in foreground."
|
||||
crond -f -d 8
|
||||
20
go.mod
20
go.mod
@@ -1,32 +1,38 @@
|
||||
module github.com/offen/docker-volume-backup
|
||||
|
||||
go 1.21
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.1
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.2.1
|
||||
github.com/containrrr/shoutrrr v0.7.1
|
||||
github.com/cosiner/argv v0.1.0
|
||||
github.com/docker/cli v24.0.9+incompatible
|
||||
github.com/docker/docker v24.0.7+incompatible
|
||||
github.com/gofrs/flock v0.8.1
|
||||
github.com/klauspost/compress v1.17.4
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/klauspost/compress v1.17.6
|
||||
github.com/leekchan/timeutil v0.0.0-20150802142658-28917288c48d
|
||||
github.com/minio/minio-go/v7 v7.0.66
|
||||
github.com/minio/minio-go/v7 v7.0.67
|
||||
github.com/offen/envconfig v1.5.0
|
||||
github.com/otiai10/copy v1.14.0
|
||||
github.com/pkg/sftp v1.13.6
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/studio-b12/gowebdav v0.9.0
|
||||
golang.org/x/crypto v0.18.0
|
||||
golang.org/x/oauth2 v0.16.0
|
||||
golang.org/x/crypto v0.19.0
|
||||
golang.org/x/oauth2 v0.17.0
|
||||
golang.org/x/sync v0.6.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 // indirect
|
||||
github.com/cloudflare/circl v1.3.7 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0 // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
golang.org/x/time v0.0.0-20220609170525-579cf78fd858 // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
mvdan.cc/sh/v3 v3.8.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -63,8 +69,8 @@ require (
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/rs/xid v1.5.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
golang.org/x/net v0.20.0 // indirect
|
||||
golang.org/x/sys v0.16.0 // indirect
|
||||
golang.org/x/net v0.21.0 // indirect
|
||||
golang.org/x/sys v0.17.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
|
||||
37
go.sum
37
go.sum
@@ -253,6 +253,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
|
||||
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
||||
github.com/docker/cli v24.0.9+incompatible h1:OxbimnP/z+qVjDLpq9wbeFU3Nc30XhSe+LkwYQisD50=
|
||||
github.com/docker/cli v24.0.9+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8=
|
||||
github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
|
||||
github.com/docker/docker v24.0.7+incompatible h1:Wo6l37AuwP3JaMnZa226lzVXGA3F9Ig1seQen0cKYlM=
|
||||
@@ -441,6 +443,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:
|
||||
github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jarcoal/httpmock v1.2.0 h1:gSvTxxFR/MEMfsGrvRbdfpRUMBStovlSRLw0Ep1bwwc=
|
||||
github.com/jarcoal/httpmock v1.2.0/go.mod h1:oCoTsnAz4+UoOUIf5lJOWV2QQIW5UoeUI6aM2YnWAZk=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
|
||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
@@ -454,8 +458,8 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V
|
||||
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
||||
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||
github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
|
||||
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc=
|
||||
github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
@@ -500,8 +504,8 @@ github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKju
|
||||
github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.66 h1:bnTOXOHjOqv/gcMuiVbN9o2ngRItvqE774dG9nq0Dzw=
|
||||
github.com/minio/minio-go/v7 v7.0.66/go.mod h1:DHAgmyQEGdW3Cif0UooKOyrT3Vxs82zNdV6tkKhRtbs=
|
||||
github.com/minio/minio-go/v7 v7.0.67 h1:BeBvZWAS+kRJm1vGTMJYVjKUNoo0FoEt/wUWdUtfmh8=
|
||||
github.com/minio/minio-go/v7 v7.0.67/go.mod h1:+UXocnUeZ3wHvVh5s95gcrA4YjMIbccT6ubB+1m054A=
|
||||
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
|
||||
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
|
||||
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
|
||||
@@ -591,6 +595,8 @@ github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsT
|
||||
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
|
||||
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
|
||||
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
@@ -673,8 +679,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
|
||||
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
|
||||
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
@@ -771,8 +777,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
|
||||
golang.org/x/net v0.0.0-20220909164309-bea034e7d591/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||
golang.org/x/net v0.0.0-20221014081412-f15817d10f9b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
|
||||
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
@@ -797,8 +803,8 @@ golang.org/x/oauth2 v0.0.0-20220622183110-fd043fe589d2/go.mod h1:jaDAt6Dkxork7Lm
|
||||
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/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg=
|
||||
golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ=
|
||||
golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o=
|
||||
golang.org/x/oauth2 v0.17.0 h1:6m3ZPmLEFdVxKKWnKq4VqZ60gutO35zm+zrAHVmHyDQ=
|
||||
golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA=
|
||||
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=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -911,13 +917,13 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE=
|
||||
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
||||
golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@@ -1241,6 +1247,7 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
|
||||
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
|
||||
gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk=
|
||||
gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0=
|
||||
@@ -1252,6 +1259,8 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||
mvdan.cc/sh/v3 v3.8.0 h1:ZxuJipLZwr/HLbASonmXtcvvC9HXY9d2lXZHnKGjFc8=
|
||||
mvdan.cc/sh/v3 v3.8.0/go.mod h1:w04623xkgBVo7/IUK89E0g8hBykgEpN0vgOj3RJr6MY=
|
||||
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
||||
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
|
||||
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
|
||||
|
||||
43
internal/errwrap/wrap.go
Normal file
43
internal/errwrap/wrap.go
Normal file
@@ -0,0 +1,43 @@
|
||||
// Copyright 2024 - Offen Authors <hioffen@posteo.de>
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package errwrap
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Wrap wraps the given error using the given message while prepending
|
||||
// the name of the calling function, creating a poor man's stack trace
|
||||
func Wrap(err error, msg string) error {
|
||||
pc := make([]uintptr, 15)
|
||||
n := runtime.Callers(2, pc)
|
||||
frames := runtime.CallersFrames(pc[:n])
|
||||
frame, _ := frames.Next()
|
||||
// strip full import paths and just use the package name
|
||||
chunks := strings.Split(frame.Function, "/")
|
||||
withCaller := fmt.Sprintf("%s: %s", chunks[len(chunks)-1], msg)
|
||||
if err == nil {
|
||||
return fmt.Errorf(withCaller)
|
||||
}
|
||||
return fmt.Errorf("%s: %w", withCaller, err)
|
||||
}
|
||||
|
||||
// Unwrap receives an error and returns the last error in the chain of
|
||||
// wrapped errors
|
||||
func Unwrap(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
for {
|
||||
u := errors.Unwrap(err)
|
||||
if u == nil {
|
||||
break
|
||||
}
|
||||
err = u
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/container"
|
||||
"github.com/offen/docker-volume-backup/internal/errwrap"
|
||||
"github.com/offen/docker-volume-backup/internal/storage"
|
||||
)
|
||||
|
||||
@@ -40,11 +41,11 @@ type Config struct {
|
||||
func NewStorageBackend(opts Config, logFunc storage.Log) (storage.Backend, error) {
|
||||
endpointTemplate, err := template.New("endpoint").Parse(opts.Endpoint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("NewStorageBackend: error parsing endpoint template: %w", err)
|
||||
return nil, errwrap.Wrap(err, "error parsing endpoint template")
|
||||
}
|
||||
var ep bytes.Buffer
|
||||
if err := endpointTemplate.Execute(&ep, opts); err != nil {
|
||||
return nil, fmt.Errorf("NewStorageBackend: error executing endpoint template: %w", err)
|
||||
return nil, errwrap.Wrap(err, "error executing endpoint template")
|
||||
}
|
||||
normalizedEndpoint := fmt.Sprintf("%s/", strings.TrimSuffix(ep.String(), "/"))
|
||||
|
||||
@@ -52,21 +53,21 @@ func NewStorageBackend(opts Config, logFunc storage.Log) (storage.Backend, error
|
||||
if opts.PrimaryAccountKey != "" {
|
||||
cred, err := azblob.NewSharedKeyCredential(opts.AccountName, opts.PrimaryAccountKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("NewStorageBackend: error creating shared key Azure credential: %w", err)
|
||||
return nil, errwrap.Wrap(err, "error creating shared key Azure credential")
|
||||
}
|
||||
|
||||
client, err = azblob.NewClientWithSharedKeyCredential(normalizedEndpoint, cred, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("NewStorageBackend: error creating Azure client: %w", err)
|
||||
return nil, errwrap.Wrap(err, "error creating Azure client")
|
||||
}
|
||||
} else {
|
||||
cred, err := azidentity.NewManagedIdentityCredential(nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("NewStorageBackend: error creating managed identity credential: %w", err)
|
||||
return nil, errwrap.Wrap(err, "error creating managed identity credential")
|
||||
}
|
||||
client, err = azblob.NewClient(normalizedEndpoint, cred, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("NewStorageBackend: error creating Azure client: %w", err)
|
||||
return nil, errwrap.Wrap(err, "error creating Azure client")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +91,7 @@ func (b *azureBlobStorage) Name() string {
|
||||
func (b *azureBlobStorage) Copy(file string) error {
|
||||
fileReader, err := os.Open(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("(*azureBlobStorage).Copy: error opening file %s: %w", file, err)
|
||||
return errwrap.Wrap(err, fmt.Sprintf("error opening file %s", file))
|
||||
}
|
||||
_, err = b.client.UploadStream(
|
||||
context.Background(),
|
||||
@@ -100,7 +101,7 @@ func (b *azureBlobStorage) Copy(file string) error {
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("(*azureBlobStorage).Copy: error uploading file %s: %w", file, err)
|
||||
return errwrap.Wrap(err, fmt.Sprintf("error uploading file %s", file))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -117,7 +118,7 @@ func (b *azureBlobStorage) Prune(deadline time.Time, pruningPrefix string) (*sto
|
||||
for pager.More() {
|
||||
resp, err := pager.NextPage(context.Background())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("(*azureBlobStorage).Prune: error paging over blobs: %w", err)
|
||||
return nil, errwrap.Wrap(err, "error paging over blobs")
|
||||
}
|
||||
for _, v := range resp.Segment.BlobItems {
|
||||
totalCount++
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"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/errwrap"
|
||||
"github.com/offen/docker-volume-backup/internal/storage"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
@@ -51,7 +52,7 @@ func NewStorageBackend(opts Config, logFunc storage.Log) (storage.Backend, error
|
||||
tkSource := conf.TokenSource(context.Background(), &oauth2.Token{RefreshToken: opts.RefreshToken})
|
||||
token, err := tkSource.Token()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("(*dropboxStorage).NewStorageBackend: Error refreshing token: %w", err)
|
||||
return nil, errwrap.Wrap(err, "error refreshing token")
|
||||
}
|
||||
|
||||
dbxConfig := dropbox.Config{
|
||||
@@ -95,29 +96,28 @@ func (b *dropboxStorage) Copy(file string) error {
|
||||
switch err := err.(type) {
|
||||
case files.CreateFolderV2APIError:
|
||||
if err.EndpointError.Path.Tag != files.WriteErrorConflict {
|
||||
return fmt.Errorf("(*dropboxStorage).Copy: Error creating directory '%s': %w", b.DestinationPath, err)
|
||||
return errwrap.Wrap(err, fmt.Sprintf("error creating directory '%s'", b.DestinationPath))
|
||||
}
|
||||
b.Log(storage.LogLevelInfo, b.Name(), "Destination path '%s' already exists, no new directory required.", b.DestinationPath)
|
||||
default:
|
||||
return fmt.Errorf("(*dropboxStorage).Copy: Error creating directory '%s': %w", b.DestinationPath, err)
|
||||
return errwrap.Wrap(err, fmt.Sprintf("error creating directory '%s'", b.DestinationPath))
|
||||
}
|
||||
}
|
||||
|
||||
r, err := os.Open(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("(*dropboxStorage).Copy: Error opening the file to be uploaded: %w", err)
|
||||
return errwrap.Wrap(err, "error opening the file to be uploaded")
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
// Start new upload session and get session id
|
||||
|
||||
b.Log(storage.LogLevelInfo, b.Name(), "Starting upload session for backup '%s' 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)
|
||||
return errwrap.Wrap(err, "error starting the upload session")
|
||||
} else {
|
||||
sessionId = res.SessionId
|
||||
}
|
||||
@@ -165,7 +165,7 @@ loop:
|
||||
|
||||
bytesRead, err := r.Read(chunk)
|
||||
if err != nil {
|
||||
errorChn <- fmt.Errorf("(*dropboxStorage).Copy: Error reading the file to be uploaded: %w", err)
|
||||
errorChn <- errwrap.Wrap(err, "error reading the file to be uploaded")
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
@@ -184,7 +184,7 @@ loop:
|
||||
mu.Unlock()
|
||||
|
||||
if err := b.client.UploadSessionAppendV2(uploadSessionAppendArg, bytes.NewReader(chunk)); err != nil {
|
||||
errorChn <- fmt.Errorf("(*dropboxStorage).Copy: Error appending the file to the upload session: %w", err)
|
||||
errorChn <- errwrap.Wrap(err, "error appending the file to the upload session")
|
||||
return
|
||||
}
|
||||
}()
|
||||
@@ -198,7 +198,7 @@ loop:
|
||||
files.NewCommitInfo(filepath.Join(b.DestinationPath, name)),
|
||||
), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("(*dropboxStorage).Copy: Error finishing the upload session: %w", err)
|
||||
return errwrap.Wrap(err, "error finishing the upload session")
|
||||
}
|
||||
|
||||
b.Log(storage.LogLevelInfo, b.Name(), "Uploaded a copy of backup '%s' at path '%s'.", file, b.DestinationPath)
|
||||
@@ -211,14 +211,14 @@ func (b *dropboxStorage) Prune(deadline time.Time, pruningPrefix string) (*stora
|
||||
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)
|
||||
return nil, errwrap.Wrap(err, "error looking up candidates from remote storage")
|
||||
}
|
||||
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)
|
||||
return nil, errwrap.Wrap(err, "error looking up candidates from remote storage")
|
||||
}
|
||||
entries = append(entries, res.Entries...)
|
||||
}
|
||||
@@ -248,7 +248,7 @@ func (b *dropboxStorage) Prune(deadline time.Time, pruningPrefix string) (*stora
|
||||
pruneErr := b.DoPrune(b.Name(), len(matches), lenCandidates, deadline, 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 errwrap.Wrap(err, "error removing file from Dropbox storage")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/offen/docker-volume-backup/internal/errwrap"
|
||||
"github.com/offen/docker-volume-backup/internal/storage"
|
||||
)
|
||||
|
||||
@@ -47,7 +48,7 @@ func (b *localStorage) Copy(file string) error {
|
||||
_, name := path.Split(file)
|
||||
|
||||
if err := copyFile(file, path.Join(b.DestinationPath, name)); err != nil {
|
||||
return fmt.Errorf("(*localStorage).Copy: Error copying file to archive: %w", err)
|
||||
return errwrap.Wrap(err, "error copying file to archive")
|
||||
}
|
||||
b.Log(storage.LogLevelInfo, b.Name(), "Stored copy of backup `%s` in `%s`.", file, b.DestinationPath)
|
||||
|
||||
@@ -57,7 +58,7 @@ func (b *localStorage) Copy(file string) error {
|
||||
os.Remove(symlink)
|
||||
}
|
||||
if err := os.Symlink(name, symlink); err != nil {
|
||||
return fmt.Errorf("(*localStorage).Copy: error creating latest symlink: %w", err)
|
||||
return errwrap.Wrap(err, "error creating latest symlink")
|
||||
}
|
||||
b.Log(storage.LogLevelInfo, b.Name(), "Created/Updated symlink `%s` for latest backup.", b.latestSymlink)
|
||||
}
|
||||
@@ -73,10 +74,12 @@ func (b *localStorage) Prune(deadline time.Time, pruningPrefix string) (*storage
|
||||
)
|
||||
globMatches, err := filepath.Glob(globPattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"(*localStorage).Prune: Error looking up matching files using pattern %s: %w",
|
||||
globPattern,
|
||||
return nil, errwrap.Wrap(
|
||||
err,
|
||||
fmt.Sprintf(
|
||||
"error looking up matching files using pattern %s",
|
||||
globPattern,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -84,10 +87,12 @@ func (b *localStorage) Prune(deadline time.Time, pruningPrefix string) (*storage
|
||||
for _, candidate := range globMatches {
|
||||
fi, err := os.Lstat(candidate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"(*localStorage).Prune: Error calling Lstat on file %s: %w",
|
||||
candidate,
|
||||
return nil, errwrap.Wrap(
|
||||
err,
|
||||
fmt.Sprintf(
|
||||
"error calling Lstat on file %s",
|
||||
candidate,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -100,10 +105,12 @@ func (b *localStorage) Prune(deadline time.Time, pruningPrefix string) (*storage
|
||||
for _, candidate := range candidates {
|
||||
fi, err := os.Stat(candidate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"(*localStorage).Prune: Error calling stat on file %s: %w",
|
||||
candidate,
|
||||
return nil, errwrap.Wrap(
|
||||
err,
|
||||
fmt.Sprintf(
|
||||
"error calling stat on file %s",
|
||||
candidate,
|
||||
),
|
||||
)
|
||||
}
|
||||
if fi.ModTime().Before(deadline) {
|
||||
@@ -124,10 +131,12 @@ func (b *localStorage) Prune(deadline time.Time, pruningPrefix string) (*storage
|
||||
}
|
||||
}
|
||||
if len(removeErrors) != 0 {
|
||||
return fmt.Errorf(
|
||||
"(*localStorage).Prune: %d error(s) deleting files, starting with: %w",
|
||||
len(removeErrors),
|
||||
return errwrap.Wrap(
|
||||
errors.Join(removeErrors...),
|
||||
fmt.Sprintf(
|
||||
"%d error(s) deleting files",
|
||||
len(removeErrors),
|
||||
),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
"github.com/offen/docker-volume-backup/internal/errwrap"
|
||||
"github.com/offen/docker-volume-backup/internal/storage"
|
||||
)
|
||||
|
||||
@@ -53,7 +54,7 @@ func NewStorageBackend(opts Config, logFunc storage.Log) (storage.Backend, error
|
||||
} else if opts.IamRoleEndpoint != "" {
|
||||
creds = credentials.NewIAM(opts.IamRoleEndpoint)
|
||||
} else {
|
||||
return nil, errors.New("NewStorageBackend: AWS_S3_BUCKET_NAME is defined, but no credentials were provided")
|
||||
return nil, errwrap.Wrap(nil, "AWS_S3_BUCKET_NAME is defined, but no credentials were provided")
|
||||
}
|
||||
|
||||
options := minio.Options{
|
||||
@@ -63,12 +64,12 @@ func NewStorageBackend(opts Config, logFunc storage.Log) (storage.Backend, error
|
||||
|
||||
transport, err := minio.DefaultTransport(true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("NewStorageBackend: failed to create default minio transport: %w", err)
|
||||
return nil, errwrap.Wrap(err, "failed to create default minio transport")
|
||||
}
|
||||
|
||||
if opts.EndpointInsecure {
|
||||
if !options.Secure {
|
||||
return nil, errors.New("NewStorageBackend: AWS_ENDPOINT_INSECURE = true is only meaningful for https")
|
||||
return nil, errwrap.Wrap(nil, "AWS_ENDPOINT_INSECURE = true is only meaningful for https")
|
||||
}
|
||||
transport.TLSClientConfig.InsecureSkipVerify = true
|
||||
} else if opts.CACert != nil {
|
||||
@@ -81,7 +82,7 @@ func NewStorageBackend(opts Config, logFunc storage.Log) (storage.Backend, error
|
||||
|
||||
mc, err := minio.New(opts.Endpoint, &options)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("NewStorageBackend: error setting up minio client: %w", err)
|
||||
return nil, errwrap.Wrap(err, "error setting up minio client")
|
||||
}
|
||||
|
||||
return &s3Storage{
|
||||
@@ -112,12 +113,12 @@ func (b *s3Storage) Copy(file string) error {
|
||||
if b.partSize > 0 {
|
||||
srcFileInfo, err := os.Stat(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("(*s3Storage).Copy: error reading the local file: %w", err)
|
||||
return errwrap.Wrap(err, "error reading the local file")
|
||||
}
|
||||
|
||||
_, partSize, _, err := minio.OptimalPartInfo(srcFileInfo.Size(), uint64(b.partSize*1024*1024))
|
||||
if err != nil {
|
||||
return fmt.Errorf("(*s3Storage).Copy: error computing the optimal s3 part size: %w", err)
|
||||
return errwrap.Wrap(err, "error computing the optimal s3 part size")
|
||||
}
|
||||
|
||||
putObjectOptions.PartSize = uint64(partSize)
|
||||
@@ -125,14 +126,17 @@ func (b *s3Storage) Copy(file string) error {
|
||||
|
||||
if _, err := b.client.FPutObject(context.Background(), b.bucket, filepath.Join(b.DestinationPath, name), file, putObjectOptions); err != nil {
|
||||
if errResp := minio.ToErrorResponse(err); errResp.Message != "" {
|
||||
return fmt.Errorf(
|
||||
"(*s3Storage).Copy: error uploading backup to remote storage: [Message]: '%s', [Code]: %s, [StatusCode]: %d",
|
||||
errResp.Message,
|
||||
errResp.Code,
|
||||
errResp.StatusCode,
|
||||
return errwrap.Wrap(
|
||||
nil,
|
||||
fmt.Sprintf(
|
||||
"error uploading backup to remote storage: [Message]: '%s', [Code]: %s, [StatusCode]: %d",
|
||||
errResp.Message,
|
||||
errResp.Code,
|
||||
errResp.StatusCode,
|
||||
),
|
||||
)
|
||||
}
|
||||
return fmt.Errorf("(*s3Storage).Copy: error uploading backup to remote storage: %w", err)
|
||||
return errwrap.Wrap(err, "error uploading backup to remote storage")
|
||||
}
|
||||
|
||||
b.Log(storage.LogLevelInfo, b.Name(), "Uploaded a copy of backup `%s` to bucket `%s`.", file, b.bucket)
|
||||
@@ -152,9 +156,9 @@ func (b *s3Storage) Prune(deadline time.Time, pruningPrefix string) (*storage.Pr
|
||||
for candidate := range candidates {
|
||||
lenCandidates++
|
||||
if candidate.Err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"(*s3Storage).Prune: error looking up candidates from remote storage! %w",
|
||||
return nil, errwrap.Wrap(
|
||||
candidate.Err,
|
||||
"error looking up candidates from remote storage",
|
||||
)
|
||||
}
|
||||
if candidate.LastModified.Before(deadline) {
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -13,6 +12,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/offen/docker-volume-backup/internal/errwrap"
|
||||
"github.com/offen/docker-volume-backup/internal/storage"
|
||||
"github.com/pkg/sftp"
|
||||
"golang.org/x/crypto/ssh"
|
||||
@@ -47,20 +47,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, errors.New("NewStorageBackend: error reading the private key")
|
||||
return nil, 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, errors.New("NewStorageBackend: error parsing the encrypted private key")
|
||||
return nil, 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, errors.New("NewStorageBackend: error parsing the private key")
|
||||
return nil, errwrap.Wrap(nil, "error parsing the private key")
|
||||
}
|
||||
authMethods = append(authMethods, ssh.PublicKeys(signer))
|
||||
}
|
||||
@@ -74,7 +74,7 @@ func NewStorageBackend(opts Config, logFunc storage.Log) (storage.Backend, error
|
||||
sshClient, err := ssh.Dial("tcp", fmt.Sprintf("%s:%s", opts.HostName, opts.Port), sshClientConfig)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("NewStorageBackend: error creating ssh client: %w", err)
|
||||
return nil, errwrap.Wrap(err, "error creating ssh client")
|
||||
}
|
||||
_, _, err = sshClient.SendRequest("keepalive", false, nil)
|
||||
if err != nil {
|
||||
@@ -87,7 +87,7 @@ func NewStorageBackend(opts Config, logFunc storage.Log) (storage.Backend, error
|
||||
sftp.MaxConcurrentRequestsPerFile(64),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("NewStorageBackend: error creating sftp client: %w", err)
|
||||
return nil, errwrap.Wrap(err, "error creating sftp client")
|
||||
}
|
||||
|
||||
return &sshStorage{
|
||||
@@ -111,13 +111,13 @@ func (b *sshStorage) Copy(file string) error {
|
||||
source, err := os.Open(file)
|
||||
_, name := path.Split(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("(*sshStorage).Copy: error reading the file to be uploaded: %w", err)
|
||||
return errwrap.Wrap(err, " error reading the file to be uploaded")
|
||||
}
|
||||
defer source.Close()
|
||||
|
||||
destination, err := b.sftpClient.Create(filepath.Join(b.DestinationPath, name))
|
||||
if err != nil {
|
||||
return fmt.Errorf("(*sshStorage).Copy: error creating file: %w", err)
|
||||
return errwrap.Wrap(err, "error creating file")
|
||||
}
|
||||
defer destination.Close()
|
||||
|
||||
@@ -127,27 +127,27 @@ func (b *sshStorage) Copy(file string) error {
|
||||
if err == io.EOF {
|
||||
tot, err := destination.Write(chunk[:num])
|
||||
if err != nil {
|
||||
return fmt.Errorf("(*sshStorage).Copy: error uploading the file: %w", err)
|
||||
return errwrap.Wrap(err, "error uploading the file")
|
||||
}
|
||||
|
||||
if tot != len(chunk[:num]) {
|
||||
return errors.New("(*sshStorage).Copy: failed to write stream")
|
||||
return errwrap.Wrap(nil, "failed to write stream")
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("(*sshStorage).Copy: error uploading the file: %w", err)
|
||||
return errwrap.Wrap(err, "error uploading the file")
|
||||
}
|
||||
|
||||
tot, err := destination.Write(chunk[:num])
|
||||
if err != nil {
|
||||
return fmt.Errorf("(*sshStorage).Copy: error uploading the file: %w", err)
|
||||
return errwrap.Wrap(err, "error uploading the file")
|
||||
}
|
||||
|
||||
if tot != len(chunk[:num]) {
|
||||
return fmt.Errorf("(*sshStorage).Copy: failed to write stream")
|
||||
return errwrap.Wrap(nil, "failed to write stream")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ func (b *sshStorage) Copy(file string) error {
|
||||
func (b *sshStorage) Prune(deadline time.Time, pruningPrefix string) (*storage.PruneStats, error) {
|
||||
candidates, err := b.sftpClient.ReadDir(b.DestinationPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("(*sshStorage).Prune: error reading directory: %w", err)
|
||||
return nil, errwrap.Wrap(err, "error reading directory")
|
||||
}
|
||||
|
||||
var matches []string
|
||||
@@ -181,7 +181,7 @@ func (b *sshStorage) Prune(deadline time.Time, pruningPrefix string) (*storage.P
|
||||
pruneErr := b.DoPrune(b.Name(), len(matches), len(candidates), deadline, func() error {
|
||||
for _, match := range matches {
|
||||
if err := b.sftpClient.Remove(filepath.Join(b.DestinationPath, match)); err != nil {
|
||||
return fmt.Errorf("(*sshStorage).Prune: error removing file: %w", err)
|
||||
return errwrap.Wrap(err, "error removing file")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/offen/docker-volume-backup/internal/errwrap"
|
||||
)
|
||||
|
||||
// Backend is an interface for defining functions which all storage providers support.
|
||||
@@ -26,7 +27,6 @@ type LogLevel int
|
||||
const (
|
||||
LogLevelInfo LogLevel = iota
|
||||
LogLevelWarning
|
||||
LogLevelError
|
||||
)
|
||||
|
||||
type Log func(logType LogLevel, context string, msg string, params ...any)
|
||||
@@ -47,7 +47,7 @@ func (b *StorageBackend) DoPrune(context string, lenMatches, lenCandidates int,
|
||||
|
||||
formattedDeadline, err := deadline.Local().MarshalText()
|
||||
if err != nil {
|
||||
return fmt.Errorf("(*StorageBackend).DoPrune: error marshaling deadline: %w", err)
|
||||
return errwrap.Wrap(err, "error marshaling deadline")
|
||||
}
|
||||
b.Log(LogLevelInfo, context,
|
||||
"Pruned %d out of %d backups as they were older than the given deadline of %s.",
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package webdav
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
@@ -14,6 +13,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/offen/docker-volume-backup/internal/errwrap"
|
||||
"github.com/offen/docker-volume-backup/internal/storage"
|
||||
"github.com/studio-b12/gowebdav"
|
||||
)
|
||||
@@ -36,14 +36,14 @@ type Config struct {
|
||||
// NewStorageBackend creates and initializes a new WebDav storage backend.
|
||||
func NewStorageBackend(opts Config, logFunc storage.Log) (storage.Backend, error) {
|
||||
if opts.Username == "" || opts.Password == "" {
|
||||
return nil, errors.New("NewStorageBackend: WEBDAV_URL is defined, but no credentials were provided")
|
||||
return nil, errwrap.Wrap(nil, "WEBDAV_URL is defined, but no credentials were provided")
|
||||
} else {
|
||||
webdavClient := gowebdav.NewClient(opts.URL, opts.Username, opts.Password)
|
||||
|
||||
if opts.URLInsecure {
|
||||
defaultTransport, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
return nil, errors.New("NewStorageBackend: unexpected error when asserting type for http.DefaultTransport")
|
||||
return nil, errwrap.Wrap(nil, "unexpected error when asserting type for http.DefaultTransport")
|
||||
}
|
||||
webdavTransport := defaultTransport.Clone()
|
||||
webdavTransport.TLSClientConfig.InsecureSkipVerify = opts.URLInsecure
|
||||
@@ -69,16 +69,16 @@ func (b *webDavStorage) Name() string {
|
||||
func (b *webDavStorage) Copy(file string) error {
|
||||
_, name := path.Split(file)
|
||||
if err := b.client.MkdirAll(b.DestinationPath, 0644); err != nil {
|
||||
return fmt.Errorf("(*webDavStorage).Copy: error creating directory '%s' on server: %w", b.DestinationPath, err)
|
||||
return errwrap.Wrap(err, fmt.Sprintf("error creating directory '%s' on server", b.DestinationPath))
|
||||
}
|
||||
|
||||
r, err := os.Open(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("(*webDavStorage).Copy: error opening the file to be uploaded: %w", err)
|
||||
return errwrap.Wrap(err, "error opening the file to be uploaded")
|
||||
}
|
||||
|
||||
if err := b.client.WriteStream(filepath.Join(b.DestinationPath, name), r, 0644); err != nil {
|
||||
return fmt.Errorf("(*webDavStorage).Copy: error uploading the file: %w", err)
|
||||
return errwrap.Wrap(err, "error uploading the file")
|
||||
}
|
||||
b.Log(storage.LogLevelInfo, b.Name(), "Uploaded a copy of backup '%s' to '%s' at path '%s'.", file, b.url, b.DestinationPath)
|
||||
|
||||
@@ -89,7 +89,7 @@ func (b *webDavStorage) Copy(file string) error {
|
||||
func (b *webDavStorage) Prune(deadline time.Time, pruningPrefix string) (*storage.PruneStats, error) {
|
||||
candidates, err := b.client.ReadDir(b.DestinationPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("(*webDavStorage).Prune: error looking up candidates from remote storage: %w", err)
|
||||
return nil, errwrap.Wrap(err, "error looking up candidates from remote storage")
|
||||
}
|
||||
var matches []fs.FileInfo
|
||||
var lenCandidates int
|
||||
@@ -111,7 +111,7 @@ func (b *webDavStorage) Prune(deadline time.Time, pruningPrefix string) (*storag
|
||||
pruneErr := b.DoPrune(b.Name(), len(matches), lenCandidates, deadline, func() error {
|
||||
for _, match := range matches {
|
||||
if err := b.client.Remove(filepath.Join(b.DestinationPath, match.Name())); err != nil {
|
||||
return fmt.Errorf("(*webDavStorage).Prune: error removing file: %w", err)
|
||||
return errwrap.Wrap(err, "error removing file")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
28
test/collision/docker-compose.yml
Normal file
28
test/collision/docker-compose.yml
Normal file
@@ -0,0 +1,28 @@
|
||||
# Copyright 2020-2021 - Offen Authors <hioffen@posteo.de>
|
||||
# SPDX-License-Identifier: Unlicense
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backup:
|
||||
image: offen/docker-volume-backup:${TEST_VERSION:-canary}
|
||||
environment:
|
||||
BACKUP_FILENAME: test.tar.gz
|
||||
volumes:
|
||||
- offen_data:/backup/offen_data:ro
|
||||
- ${LOCAL_DIR:-./local}:/archive
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
offen:
|
||||
image: offen/offen:latest
|
||||
labels:
|
||||
- docker-volume-backup.stop-during-backup=true
|
||||
deploy:
|
||||
labels:
|
||||
- docker-volume-backup.stop-during-backup=true
|
||||
replicas: 2
|
||||
volumes:
|
||||
- offen_data:/var/opt/offen
|
||||
|
||||
volumes:
|
||||
offen_data:
|
||||
34
test/collision/run.sh
Executable file
34
test/collision/run.sh
Executable file
@@ -0,0 +1,34 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
cd $(dirname $0)
|
||||
. ../util.sh
|
||||
current_test=$(basename $(pwd))
|
||||
|
||||
export LOCAL_DIR=$(mktemp -d)
|
||||
|
||||
docker swarm init
|
||||
|
||||
docker stack deploy --compose-file=docker-compose.yml test_stack
|
||||
|
||||
while [ -z $(docker ps -q -f name=backup) ]; do
|
||||
info "Backup container not ready yet. Retrying."
|
||||
sleep 1
|
||||
done
|
||||
|
||||
sleep 20
|
||||
|
||||
set +e
|
||||
docker exec $(docker ps -q -f name=backup) backup
|
||||
if [ $? = "0" ]; then
|
||||
fail "Expected script to exit with error code."
|
||||
fi
|
||||
|
||||
if [ -f "${LOCAL_DIR}/test.tar.gz" ]; then
|
||||
fail "Found backup file that should not have been created."
|
||||
fi
|
||||
|
||||
expect_running_containers "3"
|
||||
|
||||
pass "Script did not perform backup as there was a label collision."
|
||||
@@ -1,2 +1,2 @@
|
||||
BACKUP_FILENAME="conf.tar.gz"
|
||||
NAME="$EXPANSION_VALUE"
|
||||
BACKUP_CRON_EXPRESSION="*/1 * * * *"
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
BACKUP_FILENAME="other.tar.gz"
|
||||
NAME="other"
|
||||
BACKUP_CRON_EXPRESSION="*/1 * * * *"
|
||||
BACKUP_FILENAME="override-$NAME.tar.gz"
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
BACKUP_FILENAME="never.tar.gz"
|
||||
NAME="never"
|
||||
BACKUP_CRON_EXPRESSION="0 0 5 31 2 ?"
|
||||
|
||||
@@ -4,6 +4,10 @@ services:
|
||||
backup:
|
||||
image: offen/docker-volume-backup:${TEST_VERSION:-canary}
|
||||
restart: always
|
||||
environment:
|
||||
BACKUP_FILENAME: $$NAME.tar.gz
|
||||
BACKUP_FILENAME_EXPAND: 'true'
|
||||
EXPANSION_VALUE: conf
|
||||
volumes:
|
||||
- ${LOCAL_DIR:-./local}:/archive
|
||||
- app_data:/backup/app_data:ro
|
||||
|
||||
@@ -13,12 +13,14 @@ docker compose up -d --quiet-pull
|
||||
# sleep until a backup is guaranteed to have happened on the 1 minute schedule
|
||||
sleep 100
|
||||
|
||||
docker compose logs backup
|
||||
|
||||
if [ ! -f "$LOCAL_DIR/conf.tar.gz" ]; then
|
||||
fail "Config from file was not used."
|
||||
fi
|
||||
pass "Config from file was used."
|
||||
|
||||
if [ ! -f "$LOCAL_DIR/other.tar.gz" ]; then
|
||||
if [ ! -f "$LOCAL_DIR/override-other.tar.gz" ]; then
|
||||
fail "Run on same schedule did not succeed."
|
||||
fi
|
||||
pass "Run on same schedule succeeded."
|
||||
|
||||
23
test/lock/docker-compose.yml
Normal file
23
test/lock/docker-compose.yml
Normal file
@@ -0,0 +1,23 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
backup:
|
||||
image: offen/docker-volume-backup:${TEST_VERSION:-canary}
|
||||
restart: always
|
||||
environment:
|
||||
BACKUP_CRON_EXPRESSION: 0 0 5 31 2 ?
|
||||
BACKUP_RETENTION_DAYS: '7'
|
||||
volumes:
|
||||
- app_data:/backup/app_data:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ${LOCAL_DIR:-./local}:/archive
|
||||
|
||||
offen:
|
||||
image: offen/offen:latest
|
||||
labels:
|
||||
- docker-volume-backup.stop-during-backup=true
|
||||
volumes:
|
||||
- app_data:/var/opt/offen
|
||||
|
||||
volumes:
|
||||
app_data:
|
||||
34
test/lock/run.sh
Executable file
34
test/lock/run.sh
Executable file
@@ -0,0 +1,34 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
. ../util.sh
|
||||
current_test=$(basename $(pwd))
|
||||
|
||||
export LOCAL_DIR=$(mktemp -d)
|
||||
|
||||
docker compose up -d --quiet-pull
|
||||
sleep 5
|
||||
|
||||
ec=0
|
||||
|
||||
docker compose exec -e BACKUP_RETENTION_DAYS=7 -e BACKUP_FILENAME=test.tar.gz backup backup & \
|
||||
{ set +e; sleep 0.1; docker compose exec -e BACKUP_FILENAME=test2.tar.gz -e LOCK_TIMEOUT=1s backup backup; ec=$?;}
|
||||
|
||||
if [ "$ec" = "0" ]; then
|
||||
fail "Subsequent invocation exited 0"
|
||||
fi
|
||||
pass "Subsequent invocation did not exit 0"
|
||||
|
||||
sleep 5
|
||||
|
||||
if [ ! -f "${LOCAL_DIR}/test.tar.gz" ]; then
|
||||
fail "Could not find expected tar file"
|
||||
fi
|
||||
pass "Found expected tar file"
|
||||
|
||||
if [ -f "${LOCAL_DIR}/test2.tar.gz" ]; then
|
||||
fail "Subsequent invocation was expected to fail but created archive"
|
||||
fi
|
||||
pass "Subsequent invocation did not create archive"
|
||||
7
test/nonroot/01conf.env
Normal file
7
test/nonroot/01conf.env
Normal file
@@ -0,0 +1,7 @@
|
||||
AWS_ACCESS_KEY_ID="test"
|
||||
AWS_SECRET_ACCESS_KEY="GMusLtUmILge2by+z890kQ"
|
||||
AWS_ENDPOINT="minio:9000"
|
||||
AWS_ENDPOINT_PROTO="http"
|
||||
AWS_S3_BUCKET_NAME="backup"
|
||||
BACKUP_CRON_EXPRESSION="0 0 5 31 2 ?"
|
||||
BACKUP_FILENAME="test.tar.gz"
|
||||
33
test/nonroot/docker-compose.yml
Normal file
33
test/nonroot/docker-compose.yml
Normal file
@@ -0,0 +1,33 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
minio:
|
||||
image: minio/minio:RELEASE.2020-08-04T23-10-51Z
|
||||
environment:
|
||||
MINIO_ROOT_USER: test
|
||||
MINIO_ROOT_PASSWORD: test
|
||||
MINIO_ACCESS_KEY: test
|
||||
MINIO_SECRET_KEY: GMusLtUmILge2by+z890kQ
|
||||
entrypoint: /bin/ash -c 'mkdir -p /data/backup && minio server /data'
|
||||
volumes:
|
||||
- ${LOCAL_DIR:-local}:/data
|
||||
|
||||
backup:
|
||||
image: offen/docker-volume-backup:${TEST_VERSION:-canary}
|
||||
user: 1000:1000
|
||||
depends_on:
|
||||
- minio
|
||||
restart: always
|
||||
volumes:
|
||||
- app_data:/backup/app_data:ro
|
||||
- ./01conf.env:/etc/dockervolumebackup/conf.d/01conf.env
|
||||
|
||||
offen:
|
||||
image: offen/offen:latest
|
||||
labels:
|
||||
- docker-volume-backup.stop-during-backup=true
|
||||
volumes:
|
||||
- app_data:/var/opt/offen
|
||||
|
||||
volumes:
|
||||
app_data:
|
||||
27
test/nonroot/run.sh
Executable file
27
test/nonroot/run.sh
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
. ../util.sh
|
||||
current_test=$(basename $(pwd))
|
||||
|
||||
export LOCAL_DIR=$(mktemp -d)
|
||||
|
||||
docker compose up -d --quiet-pull
|
||||
sleep 5
|
||||
|
||||
docker compose logs backup
|
||||
|
||||
# conf.d is used to confirm /etc files are also accessible for non-root users
|
||||
docker compose exec backup /bin/sh -c 'set -a; source /etc/dockervolumebackup/conf.d/01conf.env; set +a && backup'
|
||||
|
||||
sleep 5
|
||||
|
||||
expect_running_containers "3"
|
||||
|
||||
if [ ! -f "$LOCAL_DIR/backup/test.tar.gz" ]; then
|
||||
fail "Could not find archive."
|
||||
fi
|
||||
pass "Archive was created."
|
||||
|
||||
40
test/proxy/docker-compose.swarm.yml
Normal file
40
test/proxy/docker-compose.swarm.yml
Normal file
@@ -0,0 +1,40 @@
|
||||
# Copyright 2020-2021 - Offen Authors <hioffen@posteo.de>
|
||||
# SPDX-License-Identifier: Unlicense
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backup:
|
||||
image: offen/docker-volume-backup:${TEST_VERSION:-canary}
|
||||
environment:
|
||||
BACKUP_FILENAME: test.tar.gz
|
||||
BACKUP_CRON_EXPRESSION: 0 0 5 31 2 ?
|
||||
DOCKER_HOST: tcp://docker_socket_proxy:2375
|
||||
volumes:
|
||||
- pg_data:/backup/pg_data:ro
|
||||
- ${LOCAL_DIR:-local}:/archive
|
||||
|
||||
docker_socket_proxy:
|
||||
image: tecnativa/docker-socket-proxy:0.1
|
||||
environment:
|
||||
INFO: ${ALLOW_INFO:-1}
|
||||
CONTAINERS: ${ALLOW_CONTAINERS:-1}
|
||||
SERVICES: ${ALLOW_SERVICES:-1}
|
||||
POST: ${ALLOW_POST:-1}
|
||||
TASKS: ${ALLOW_TASKS:-1}
|
||||
NODES: ${ALLOW_NODES:-1}
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
pg:
|
||||
image: postgres:14-alpine
|
||||
environment:
|
||||
POSTGRES_PASSWORD: example
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
deploy:
|
||||
labels:
|
||||
- docker-volume-backup.stop-during-backup=true
|
||||
|
||||
volumes:
|
||||
pg_data:
|
||||
36
test/proxy/docker-compose.yml
Normal file
36
test/proxy/docker-compose.yml
Normal file
@@ -0,0 +1,36 @@
|
||||
# Copyright 2020-2021 - Offen Authors <hioffen@posteo.de>
|
||||
# SPDX-License-Identifier: Unlicense
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backup:
|
||||
image: offen/docker-volume-backup:${TEST_VERSION:-canary}
|
||||
environment:
|
||||
BACKUP_FILENAME: test.tar.gz
|
||||
BACKUP_CRON_EXPRESSION: 0 0 5 31 2 ?
|
||||
DOCKER_HOST: tcp://docker_socket_proxy:2375
|
||||
volumes:
|
||||
- pg_data:/backup/pg_data:ro
|
||||
- ${LOCAL_DIR:-local}:/archive
|
||||
|
||||
docker_socket_proxy:
|
||||
image: tecnativa/docker-socket-proxy:0.1
|
||||
environment:
|
||||
INFO: ${ALLOW_INFO:-1}
|
||||
CONTAINERS: ${ALLOW_CONTAINERS:-1}
|
||||
POST: ${ALLOW_POST:-1}
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
pg:
|
||||
image: postgres:14-alpine
|
||||
environment:
|
||||
POSTGRES_PASSWORD: example
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
labels:
|
||||
- docker-volume-backup.stop-during-backup=true
|
||||
|
||||
volumes:
|
||||
pg_data:
|
||||
76
test/proxy/run.sh
Executable file
76
test/proxy/run.sh
Executable file
@@ -0,0 +1,76 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
cd $(dirname $0)
|
||||
. ../util.sh
|
||||
current_test=$(basename $(pwd))
|
||||
|
||||
export LOCAL_DIR=$(mktemp -d)
|
||||
|
||||
docker compose up -d --quiet-pull
|
||||
sleep 5
|
||||
|
||||
# The default configuration in docker-compose.yml should
|
||||
# successfully create a backup.
|
||||
docker compose exec backup backup
|
||||
|
||||
sleep 5
|
||||
|
||||
expect_running_containers "3"
|
||||
|
||||
if [ ! -f "$LOCAL_DIR/test.tar.gz" ]; then
|
||||
fail "Archive was not created"
|
||||
fi
|
||||
pass "Found relevant archive file."
|
||||
|
||||
# Disabling POST should make the backup run fail
|
||||
ALLOW_POST="0" docker compose up -d
|
||||
sleep 5
|
||||
|
||||
set +e
|
||||
docker compose exec backup backup
|
||||
if [ $? = "0" ]; then
|
||||
fail "Expected invocation to exit non-zero."
|
||||
fi
|
||||
set -e
|
||||
pass "Invocation exited non-zero."
|
||||
|
||||
docker compose down --volumes
|
||||
|
||||
# Next, the test is run against a Swarm setup
|
||||
|
||||
docker swarm init
|
||||
|
||||
export LOCAL_DIR=$(mktemp -d)
|
||||
|
||||
docker stack deploy --compose-file=docker-compose.swarm.yml test_stack
|
||||
|
||||
sleep 20
|
||||
|
||||
# The default configuration in docker-compose.swarm.yml should
|
||||
# successfully create a backup in Swarm mode.
|
||||
docker exec $(docker ps -q -f name=backup) backup
|
||||
|
||||
if [ ! -f "$LOCAL_DIR/test.tar.gz" ]; then
|
||||
fail "Archive was not created"
|
||||
fi
|
||||
|
||||
pass "Found relevant archive file."
|
||||
|
||||
sleep 5
|
||||
expect_running_containers "3"
|
||||
|
||||
# Disabling POST should make the backup run fail
|
||||
ALLOW_POST="0" docker stack deploy --compose-file=docker-compose.swarm.yml test_stack
|
||||
|
||||
sleep 20
|
||||
|
||||
set +e
|
||||
docker exec $(docker ps -q -f name=backup) backup
|
||||
if [ $? = "0" ]; then
|
||||
fail "Expected invocation to exit non-zero."
|
||||
fi
|
||||
set -e
|
||||
|
||||
pass "Invocation exited non-zero."
|
||||
57
test/services/docker-compose.yml
Normal file
57
test/services/docker-compose.yml
Normal file
@@ -0,0 +1,57 @@
|
||||
# Copyright 2020-2021 - Offen Authors <hioffen@posteo.de>
|
||||
# SPDX-License-Identifier: Unlicense
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
minio:
|
||||
image: minio/minio:RELEASE.2020-08-04T23-10-51Z
|
||||
environment:
|
||||
MINIO_ROOT_USER: test
|
||||
MINIO_ROOT_PASSWORD: test
|
||||
MINIO_ACCESS_KEY: test
|
||||
MINIO_SECRET_KEY: GMusLtUmILge2by+z890kQ
|
||||
entrypoint: /bin/ash -c 'mkdir -p /data/backup && minio server /data'
|
||||
volumes:
|
||||
- backup_data:/data
|
||||
|
||||
backup:
|
||||
image: offen/docker-volume-backup:${TEST_VERSION:-canary}
|
||||
depends_on:
|
||||
- minio
|
||||
environment:
|
||||
AWS_ACCESS_KEY_ID: test
|
||||
AWS_SECRET_ACCESS_KEY: GMusLtUmILge2by+z890kQ
|
||||
AWS_ENDPOINT: minio:9000
|
||||
AWS_ENDPOINT_PROTO: http
|
||||
AWS_S3_BUCKET_NAME: backup
|
||||
BACKUP_FILENAME: test.tar.gz
|
||||
BACKUP_CRON_EXPRESSION: 0 0 5 31 2 ?
|
||||
BACKUP_RETENTION_DAYS: 7
|
||||
BACKUP_PRUNING_LEEWAY: 5s
|
||||
volumes:
|
||||
- pg_data:/backup/pg_data:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
offen:
|
||||
image: offen/offen:latest
|
||||
deploy:
|
||||
labels:
|
||||
- docker-volume-backup.stop-during-backup=true
|
||||
replicas: 2
|
||||
|
||||
pg:
|
||||
image: postgres:14-alpine
|
||||
environment:
|
||||
POSTGRES_PASSWORD: example
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
deploy:
|
||||
labels:
|
||||
- docker-volume-backup.stop-during-backup=true
|
||||
|
||||
volumes:
|
||||
backup_data:
|
||||
name: backup_data
|
||||
pg_data:
|
||||
name: pg_data
|
||||
29
test/services/run.sh
Executable file
29
test/services/run.sh
Executable file
@@ -0,0 +1,29 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
cd $(dirname $0)
|
||||
. ../util.sh
|
||||
current_test=$(basename $(pwd))
|
||||
|
||||
docker swarm init
|
||||
|
||||
docker stack deploy --compose-file=docker-compose.yml test_stack
|
||||
|
||||
while [ -z $(docker ps -q -f name=backup) ]; do
|
||||
info "Backup container not ready yet. Retrying."
|
||||
sleep 1
|
||||
done
|
||||
|
||||
sleep 20
|
||||
|
||||
docker exec $(docker ps -q -f name=backup) backup
|
||||
|
||||
docker run --rm \
|
||||
-v backup_data:/data alpine \
|
||||
ash -c 'tar -xf /data/backup/test.tar.gz && test -f /backup/pg_data/PG_VERSION'
|
||||
|
||||
pass "Found relevant files in untared backup."
|
||||
|
||||
sleep 5
|
||||
expect_running_containers "5"
|
||||
@@ -22,7 +22,7 @@ skip () {
|
||||
|
||||
expect_running_containers () {
|
||||
if [ "$(docker ps -q | wc -l)" != "$1" ]; then
|
||||
fail "Expected $1 containers to be running, instead seen: "$(docker ps -a | wc -l)""
|
||||
fail "Expected $1 containers to be running, instead seen: "$(docker ps -q | wc -l)""
|
||||
fi
|
||||
pass "$1 containers running."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user