Fine grained labels (#115)

* Refactor label command mechanism to be more flexible

* Run all steps wrapped in labeled commands

* Rename methods to be in line with lifecycle

* Deprecate exec-pre and exec-post labels

* Add documentation

* Use type alias for lifecycle phases

* Fix bad imports

* Fix command lookup for deprecated labels

* Use more generic naming for lifecycle phase

* Fail on erroneous post command

* Update documentation
This commit is contained in:
Frederik Ring 2022-07-10 10:36:56 +02:00 committed by GitHub
parent 82f66565da
commit b441cf3e2b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 128 additions and 52 deletions

View File

@ -20,7 +20,7 @@ It handles __recurring or one-off backups of Docker volumes__ to a __local direc
- [Automatically pruning old backups](#automatically-pruning-old-backups) - [Automatically pruning old backups](#automatically-pruning-old-backups)
- [Send email notifications on failed backup runs](#send-email-notifications-on-failed-backup-runs) - [Send email notifications on failed backup runs](#send-email-notifications-on-failed-backup-runs)
- [Customize notifications](#customize-notifications) - [Customize notifications](#customize-notifications)
- [Run custom commands before / after backup](#run-custom-commands-before--after-backup) - [Run custom commands during the backup lifecycle](#run-custom-commands-during-the-backup-lifecycle)
- [Encrypting your backup using GPG](#encrypting-your-backup-using-gpg) - [Encrypting your backup using GPG](#encrypting-your-backup-using-gpg)
- [Restoring a volume from a backup](#restoring-a-volume-from-a-backup) - [Restoring a volume from a backup](#restoring-a-volume-from-a-backup)
- [Set the timezone the container runs in](#set-the-timezone-the-container-runs-in) - [Set the timezone the container runs in](#set-the-timezone-the-container-runs-in)
@ -28,6 +28,7 @@ It handles __recurring or one-off backups of Docker volumes__ to a __local direc
- [Manually triggering a backup](#manually-triggering-a-backup) - [Manually triggering a backup](#manually-triggering-a-backup)
- [Update deprecated email configuration](#update-deprecated-email-configuration) - [Update deprecated email configuration](#update-deprecated-email-configuration)
- [Replace deprecated `BACKUP_FROM_SNAPSHOT` usage](#replace-deprecated-backup_from_snapshot-usage) - [Replace deprecated `BACKUP_FROM_SNAPSHOT` usage](#replace-deprecated-backup_from_snapshot-usage)
- [Replace deprecated `exec-pre` and `exec-post` labels](#replace-deprecated-exec-pre-and-exec-post-labels)
- [Using a custom Docker host](#using-a-custom-docker-host) - [Using a custom Docker host](#using-a-custom-docker-host)
- [Run multiple backup schedules in the same container](#run-multiple-backup-schedules-in-the-same-container) - [Run multiple backup schedules in the same container](#run-multiple-backup-schedules-in-the-same-container)
- [Define different retention schedules](#define-different-retention-schedules) - [Define different retention schedules](#define-different-retention-schedules)
@ -351,7 +352,7 @@ You can populate below template according to your requirements and use it as you
# It is possible to define commands to be run in any container before and after # It is possible to define commands to be run in any container before and after
# a backup is conducted. The commands themselves are defined in labels like # a backup is conducted. The commands themselves are defined in labels like
# `docker-volume-backup.exec-pre=/bin/sh -c 'mysqldump [options] > dump.sql'. # `docker-volume-backup.archive-pre=/bin/sh -c 'mysqldump [options] > dump.sql'.
# Several options exist for controlling this feature: # Several options exist for controlling this feature:
# By default, any output of such a command is suppressed. If this value # By default, any output of such a command is suppressed. If this value
@ -543,11 +544,16 @@ Overridable template names are: `title_success`, `body_success`, `title_failure`
For a full list of available variables and functions, see [this page](https://github.com/offen/docker-volume-backup/blob/master/docs/NOTIFICATION-TEMPLATES.md). For a full list of available variables and functions, see [this page](https://github.com/offen/docker-volume-backup/blob/master/docs/NOTIFICATION-TEMPLATES.md).
### Run custom commands before / after backup ### Run custom commands during the backup lifecycle
In certain scenarios it can be required to run specific commands before and after a backup is taken (e.g. dumping a database). In certain scenarios it can be required to run specific commands before and after a backup is taken (e.g. dumping a database).
When mounting the Docker socket into the `docker-volume-backup` container, you can define pre- and post-commands that will be run in the context of the target container. When mounting the Docker socket into the `docker-volume-backup` container, you can define pre- and post-commands that will be run in the context of the target container (it is also possible to run commands inside the `docker-volume-backup` container itself using this feature).
Such commands are defined by specifying the command in a `docker-volume-backup.exec-[pre|post]` label. Such commands are defined by specifying the command in a `docker-volume-backup.[step]-[pre|post]` label where `step` can be any of the following phases of a backup lifecyle:
- `archive` (the tar archive is created)
- `process` (the tar archive is processed, e.g. encrypted - optional)
- `copy` (the tar archive is copied to all configured storages)
- `prune` (existing backups are pruned based on the defined ruleset - optional)
Taking a database dump using `mysqldump` would look like this: Taking a database dump using `mysqldump` would look like this:
@ -561,7 +567,7 @@ services:
volumes: volumes:
- backup_data:/tmp/backups - backup_data:/tmp/backups
labels: labels:
- docker-volume-backup.exec-pre=/bin/sh -c 'mysqldump --all-databases > /backups/dump.sql' - docker-volume-backup.archive-pre=/bin/sh -c 'mysqldump --all-databases > /backups/dump.sql'
volumes: volumes:
backup_data: backup_data:
@ -581,7 +587,7 @@ services:
volumes: volumes:
- backup_data:/tmp/backups - backup_data:/tmp/backups
labels: labels:
- docker-volume-backup.exec-pre=/bin/sh -c 'mysqldump --all-databases > /tmp/volume/dump.sql' - docker-volume-backup.archive-pre=/bin/sh -c 'mysqldump --all-databases > /tmp/volume/dump.sql'
- docker-volume-backup.exec-label=database - docker-volume-backup.exec-label=database
backup: backup:
@ -597,7 +603,7 @@ volumes:
``` ```
The backup procedure is guaranteed to wait for all `pre` commands to finish. The backup procedure is guaranteed to wait for all `pre` or `post` commands to finish before proceeding.
However there are no guarantees about the order in which they are run, which could also happen concurrently. However there are no guarantees about the order in which they are run, which could also happen concurrently.
### Encrypting your backup using GPG ### Encrypting your backup using GPG
@ -723,7 +729,7 @@ NOTIFICATION_URLS=smtp://me:secret@posteo.de:587/?fromAddress=no-reply@example.c
### Replace deprecated `BACKUP_FROM_SNAPSHOT` usage ### Replace deprecated `BACKUP_FROM_SNAPSHOT` usage
Starting with version 2.15.0, the `BACKUP_FROM_SNAPSHOT` feature has been deprecated. Starting with version 2.15.0, the `BACKUP_FROM_SNAPSHOT` feature has been deprecated.
If you need to prepare your sources before the backup is taken, use `exec-pre`, `exec-post` and an intermediate volume: If you need to prepare your sources before the backup is taken, use `archive-pre`, `archive-post` and an intermediate volume:
```yml ```yml
version: '3' version: '3'
@ -735,8 +741,8 @@ services:
- data:/var/my_app - data:/var/my_app
- backup:/tmp/backup - backup:/tmp/backup
labels: labels:
- docker-volume-backup.exec-pre=cp -r /var/my_app /tmp/backup/my-app - docker-volume-backup.archive-pre=cp -r /var/my_app /tmp/backup/my-app
- docker-volume-backup.exec-post=rm -rf /tmp/backup/my-app - docker-volume-backup.archive-post=rm -rf /tmp/backup/my-app
backup: backup:
image: offen/docker-volume-backup:latest image: offen/docker-volume-backup:latest
@ -751,6 +757,23 @@ volumes:
backup: backup:
``` ```
### Replace deprecated `exec-pre` and `exec-post` labels
Version 2.19.0 introduced the option to run labeled commands at multiple points in time during the backup lifecycle.
In order to be able to use more obvious terminology in the new labels, the existing `exec-pre` and `exec-post` labels have been deprecated.
If you want to emulate the existing behavior, all you need to do is change `exec-pre` to `archive-pre` and `exec-post` to `archive-post`:
```diff
labels:
- - docker-volume-backup.exec-pre=cp -r /var/my_app /tmp/backup/my-app
+ - docker-volume-backup.archive-pre=cp -r /var/my_app /tmp/backup/my-app
- - docker-volume-backup.exec-post=rm -rf /tmp/backup/my-app
+ - docker-volume-backup.archive-post=rm -rf /tmp/backup/my-app
```
The `EXEC_LABEL` setting and the `docker-volume-backup.exec-label` label stay as is.
Check the additional documentation on running commands during the backup lifecycle to find out about further possibilities.
### Using a custom Docker host ### Using a custom Docker host
If you are interfacing with Docker via TCP, set `DOCKER_HOST` to the correct URL. If you are interfacing with Docker via TCP, set `DOCKER_HOST` to the correct URL.

View File

@ -93,16 +93,68 @@ func (s *script) runLabeledCommands(label string) error {
return fmt.Errorf("runLabeledCommands: error querying for containers: %w", err) return fmt.Errorf("runLabeledCommands: error querying for containers: %w", err)
} }
var hasDeprecatedContainers bool
if label == "docker-volume-backup.archive-pre" {
f[0] = filters.KeyValuePair{
Key: "label",
Value: "docker-volume-backup.exec-pre",
}
deprecatedContainers, err := s.cli.ContainerList(context.Background(), types.ContainerListOptions{
Quiet: true,
Filters: filters.NewArgs(f...),
})
if err != nil {
return fmt.Errorf("runLabeledCommands: error querying for containers: %w", err)
}
if len(deprecatedContainers) != 0 {
hasDeprecatedContainers = true
containersWithCommand = append(containersWithCommand, deprecatedContainers...)
}
}
if label == "docker-volume-backup.archive-post" {
f[0] = filters.KeyValuePair{
Key: "label",
Value: "docker-volume-backup.exec-post",
}
deprecatedContainers, err := s.cli.ContainerList(context.Background(), types.ContainerListOptions{
Quiet: true,
Filters: filters.NewArgs(f...),
})
if err != nil {
return fmt.Errorf("runLabeledCommands: error querying for containers: %w", err)
}
if len(deprecatedContainers) != 0 {
hasDeprecatedContainers = true
containersWithCommand = append(containersWithCommand, deprecatedContainers...)
}
}
if len(containersWithCommand) == 0 { if len(containersWithCommand) == 0 {
return nil return nil
} }
if hasDeprecatedContainers {
s.logger.Warn(
"Using `docker-volume-backup.exec-pre` and `docker-volume-backup.exec-post` labels has been deprecated and will be removed in the next major version.",
)
s.logger.Warn(
"Please use other `-pre` and `-post` labels instead. Refer to the README for an upgrade guide.",
)
}
g := new(errgroup.Group) g := new(errgroup.Group)
for _, container := range containersWithCommand { for _, container := range containersWithCommand {
c := container c := container
g.Go(func() error { g.Go(func() error {
cmd, _ := c.Labels[label] cmd, ok := c.Labels[label]
if !ok && label == "docker-volume-backup.archive-pre" {
cmd, _ = c.Labels["docker-volume-backup.exec-pre"]
} else if !ok && label == "docker-volume-backup.archive-post" {
cmd, _ = c.Labels["docker-volume-backup.exec-post"]
}
s.logger.Infof("Running %s command %s for container %s", label, cmd, strings.TrimPrefix(c.Names[0], "/")) s.logger.Infof("Running %s command %s for container %s", label, cmd, strings.TrimPrefix(c.Names[0], "/"))
stdout, stderr, err := s.exec(c.ID, cmd) stdout, stderr, err := s.exec(c.ID, cmd)
if s.c.ExecForwardOutput { if s.c.ExecForwardOutput {
@ -121,3 +173,27 @@ func (s *script) runLabeledCommands(label string) error {
} }
return nil return nil
} }
type lifecyclePhase string
const (
lifecyclePhaseArchive lifecyclePhase = "archive"
lifecyclePhaseProcess lifecyclePhase = "process"
lifecyclePhaseCopy lifecyclePhase = "copy"
lifecyclePhasePrune lifecyclePhase = "prune"
)
func (s *script) withLabeledCommands(step lifecyclePhase, cb func() error) func() error {
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)
}
defer func() {
s.must(s.runLabeledCommands(fmt.Sprintf("docker-volume-backup.%s-post", step)))
}()
return cb()
}
}

View File

@ -38,14 +38,7 @@ func main() {
s.logger.Info("Finished running backup tasks.") s.logger.Info("Finished running backup tasks.")
}() }()
s.must(func() error { s.must(s.withLabeledCommands(lifecyclePhaseArchive, func() error {
runPostCommands, err := s.runCommands()
defer func() {
s.must(runPostCommands())
}()
if err != nil {
return err
}
restartContainers, err := s.stopContainers() restartContainers, err := s.stopContainers()
// The mechanism for restarting containers is not using hooks as it // The mechanism for restarting containers is not using hooks as it
// should happen as soon as possible (i.e. before uploading backups or // should happen as soon as possible (i.e. before uploading backups or
@ -56,10 +49,10 @@ func main() {
if err != nil { if err != nil {
return err return err
} }
return s.takeBackup() return s.createArchive()
}()) })())
s.must(s.encryptBackup()) s.must(s.withLabeledCommands(lifecyclePhaseProcess, s.encryptArchive)())
s.must(s.copyBackup()) s.must(s.withLabeledCommands(lifecyclePhaseCopy, s.copyArchive)())
s.must(s.pruneBackups()) s.must(s.withLabeledCommands(lifecyclePhasePrune, s.pruneBackups)())
} }

View File

@ -18,9 +18,6 @@ import (
"text/template" "text/template"
"time" "time"
"github.com/pkg/sftp"
"golang.org/x/crypto/ssh"
"github.com/containrrr/shoutrrr" "github.com/containrrr/shoutrrr"
"github.com/containrrr/shoutrrr/pkg/router" "github.com/containrrr/shoutrrr/pkg/router"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
@ -32,9 +29,11 @@ import (
"github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials" "github.com/minio/minio-go/v7/pkg/credentials"
"github.com/otiai10/copy" "github.com/otiai10/copy"
"github.com/pkg/sftp"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/studio-b12/gowebdav" "github.com/studio-b12/gowebdav"
"golang.org/x/crypto/openpgp" "golang.org/x/crypto/openpgp"
"golang.org/x/crypto/ssh"
) )
// script holds all the stateful information required to orchestrate a // script holds all the stateful information required to orchestrate a
@ -282,22 +281,6 @@ func newScript() (*script, error) {
return s, nil return s, nil
} }
func (s *script) runCommands() (func() error, error) {
if s.cli == nil {
return noop, nil
}
if err := s.runLabeledCommands("docker-volume-backup.exec-pre"); err != nil {
return noop, fmt.Errorf("runCommands: error running pre commands: %w", err)
}
return func() error {
if err := s.runLabeledCommands("docker-volume-backup.exec-post"); err != nil {
return fmt.Errorf("runCommands: error running post commands: %w", err)
}
return nil
}, nil
}
// stopContainers stops all Docker containers that are marked as to being // stopContainers stops all Docker containers that are marked as to being
// stopped during the backup and returns a function that can be called to // stopped during the backup and returns a function that can be called to
// restart everything that has been stopped. // restart everything that has been stopped.
@ -417,9 +400,9 @@ func (s *script) stopContainers() (func() error, error) {
}, stopError }, stopError
} }
// takeBackup creates a tar archive of the configured backup location and // createArchive creates a tar archive of the configured backup location and
// saves it to disk. // saves it to disk.
func (s *script) takeBackup() error { func (s *script) createArchive() error {
backupSources := s.c.BackupSources backupSources := s.c.BackupSources
if s.c.BackupFromSnapshot { if s.c.BackupFromSnapshot {
@ -427,7 +410,7 @@ func (s *script) takeBackup() error {
"Using BACKUP_FROM_SNAPSHOT has been deprecated and will be removed in the next major version.", "Using BACKUP_FROM_SNAPSHOT has been deprecated and will be removed in the next major version.",
) )
s.logger.Warn( s.logger.Warn(
"Please use `exec-pre` and `exec-post` commands to prepare your backup sources. Refer to the README for an upgrade guide.", "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) backupSources = filepath.Join("/tmp", s.c.BackupSources)
// copy before compressing guard against a situation where backup folder's content are still growing. // copy before compressing guard against a situation where backup folder's content are still growing.
@ -484,10 +467,10 @@ func (s *script) takeBackup() error {
return nil return nil
} }
// encryptBackup encrypts the backup file using PGP and the configured passphrase. // encryptArchive encrypts the backup file using PGP and the configured passphrase.
// In case no passphrase is given it returns early, leaving the backup file // In case no passphrase is given it returns early, leaving the backup file
// untouched. // untouched.
func (s *script) encryptBackup() error { func (s *script) encryptArchive() error {
if s.c.GpgPassphrase == "" { if s.c.GpgPassphrase == "" {
return nil return nil
} }
@ -531,9 +514,9 @@ func (s *script) encryptBackup() error {
return nil return nil
} }
// copyBackup makes sure the backup file is copied to both local and remote locations // copyArchive makes sure the backup file is copied to both local and remote locations
// as per the given configuration. // as per the given configuration.
func (s *script) copyBackup() error { func (s *script) copyArchive() error {
_, name := path.Split(s.file) _, name := path.Split(s.file)
if stat, err := os.Stat(s.file); err != nil { if stat, err := os.Stat(s.file); err != nil {
return fmt.Errorf("copyBackup: unable to stat backup file: %w", err) return fmt.Errorf("copyBackup: unable to stat backup file: %w", err)

View File

@ -10,8 +10,9 @@ services:
MARIADB_ROOT_PASSWORD: test MARIADB_ROOT_PASSWORD: test
MARIADB_DATABASE: backup MARIADB_DATABASE: backup
labels: labels:
# this is testing the deprecated label on purpose
- docker-volume-backup.exec-pre=/bin/sh -c 'mysqldump -ptest --all-databases > /tmp/volume/dump.sql' - docker-volume-backup.exec-pre=/bin/sh -c 'mysqldump -ptest --all-databases > /tmp/volume/dump.sql'
- docker-volume-backup.exec-post=/bin/sh -c 'echo "post" > /tmp/volume/post.txt' - docker-volume-backup.copy-post=/bin/sh -c 'echo "post" > /tmp/volume/post.txt'
- docker-volume-backup.exec-label=test - docker-volume-backup.exec-label=test
volumes: volumes:
- app_data:/tmp/volume - app_data:/tmp/volume