diff --git a/cmd/backup/config.go b/cmd/backup/config.go index 692214da..97e85f57 100644 --- a/cmd/backup/config.go +++ b/cmd/backup/config.go @@ -48,6 +48,7 @@ type Config struct { BackupStopContainerLabel string `split_words:"true"` BackupStopDuringBackupLabel string `split_words:"true" default:"true"` BackupStopDuringBackupNoRestartLabel string `split_words:"true" default:"true"` + BackupLabelMatchBehavior MatchBehavior `split_words:"true" default:"match"` BackupStopServiceTimeout time.Duration `split_words:"true" default:"5m"` BackupFromSnapshot bool `split_words:"true"` BackupExcludeRegexp RegexpDecoder `split_words:"true"` @@ -120,6 +121,27 @@ func (c *CompressionType) String() string { return string(*c) } +// MatchBehavior controls how the value of a container's stop-during-backup +// label is matched against the value configured on this instance. With "match" +// the values have to be equal. With "one-of" the container's label value is +// split on commas so a single container can be targeted by multiple instances, +// each configured with a different value. +type MatchBehavior string + +func (l *MatchBehavior) Decode(v string) error { + switch v { + case "match", "one-of": + *l = MatchBehavior(v) + return nil + default: + return errwrap.Wrap(nil, fmt.Sprintf("error decoding label match behavior %s, expected one of \"match\" or \"one-of\"", v)) + } +} + +func (l *MatchBehavior) String() string { + return string(*l) +} + type CertDecoder struct { Cert *x509.Certificate } diff --git a/cmd/backup/stop_restart.go b/cmd/backup/stop_restart.go index 9ea4b384..f13c839f 100644 --- a/cmd/backup/stop_restart.go +++ b/cmd/backup/stop_restart.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "io" + "strings" "sync" "time" @@ -85,14 +86,25 @@ func isSwarm(c interface { return result.Info.Swarm.LocalNodeState != "" && result.Info.Swarm.LocalNodeState != swarm.LocalNodeStateInactive && result.Info.Swarm.ControlAvailable, nil } -func hasLabel(labels map[string]string, key, value string) bool { +func hasLabel(labels map[string]string, key, value string, matchBehavior MatchBehavior) bool { val, ok := labels[key] - return ok && val == value + if !ok { + return false + } + if matchBehavior == "one-of" { + for _, candidate := range strings.Split(val, ",") { + if strings.TrimSpace(candidate) == value { + return true + } + } + return false + } + return val == value } -func checkStopLabels(labels map[string]string, stopDuringBackupLabelValue string, stopDuringBackupNoRestartLabelValue string) (bool, bool, error) { - hasStopDuringBackupLabel := hasLabel(labels, "docker-volume-backup.stop-during-backup", stopDuringBackupLabelValue) - hasStopDuringBackupNoRestartLabel := hasLabel(labels, "docker-volume-backup.stop-during-backup-no-restart", stopDuringBackupNoRestartLabelValue) +func checkStopLabels(labels map[string]string, stopDuringBackupLabelValue string, stopDuringBackupNoRestartLabelValue string, matchBehavior MatchBehavior) (bool, bool, error) { + hasStopDuringBackupLabel := hasLabel(labels, "docker-volume-backup.stop-during-backup", stopDuringBackupLabelValue, matchBehavior) + hasStopDuringBackupNoRestartLabel := hasLabel(labels, "docker-volume-backup.stop-during-backup-no-restart", stopDuringBackupNoRestartLabelValue, matchBehavior) if hasStopDuringBackupLabel && hasStopDuringBackupNoRestartLabel { return hasStopDuringBackupLabel, hasStopDuringBackupNoRestartLabel, errwrap.Wrap(nil, "both docker-volume-backup.stop-during-backup and docker-volume-backup.stop-during-backup-no-restart have been set, cannot continue") } @@ -129,7 +141,7 @@ func (s *script) stopContainersAndServices() (func() error, error) { var containersToStop []handledContainer for _, c := range allContainers.Items { - hasStopDuringBackupLabel, hasStopDuringBackupNoRestartLabel, err := checkStopLabels(c.Labels, s.c.BackupStopDuringBackupLabel, s.c.BackupStopDuringBackupNoRestartLabel) + hasStopDuringBackupLabel, hasStopDuringBackupNoRestartLabel, err := checkStopLabels(c.Labels, s.c.BackupStopDuringBackupLabel, s.c.BackupStopDuringBackupNoRestartLabel, s.c.BackupLabelMatchBehavior) if err != nil { return noop, errwrap.Wrap(err, "error querying for containers to stop") } @@ -154,7 +166,7 @@ func (s *script) stopContainersAndServices() (func() error, error) { } for _, service := range allServices { - hasStopDuringBackupLabel, hasStopDuringBackupNoRestartLabel, err := checkStopLabels(service.Spec.Labels, s.c.BackupStopDuringBackupLabel, s.c.BackupStopDuringBackupNoRestartLabel) + hasStopDuringBackupLabel, hasStopDuringBackupNoRestartLabel, err := checkStopLabels(service.Spec.Labels, s.c.BackupStopDuringBackupLabel, s.c.BackupStopDuringBackupNoRestartLabel, s.c.BackupLabelMatchBehavior) if err != nil { return noop, errwrap.Wrap(err, "error querying for services to scale down") } diff --git a/cmd/backup/stop_restart_test.go b/cmd/backup/stop_restart_test.go index 36ec02d0..6961b6fd 100644 --- a/cmd/backup/stop_restart_test.go +++ b/cmd/backup/stop_restart_test.go @@ -19,6 +19,91 @@ func (m *mockInfoClient) Info(context.Context, client.InfoOptions) (client.Syste return m.result, m.err } +func TestHasLabel(t *testing.T) { + tests := []struct { + name string + labels map[string]string + key string + value string + matchBehavior MatchBehavior + expected bool + }{ + { + "match exact", + map[string]string{"docker-volume-backup.stop-during-backup": "service1"}, + "docker-volume-backup.stop-during-backup", + "service1", + "match", + true, + }, + { + "match mismatch", + map[string]string{"docker-volume-backup.stop-during-backup": "service2"}, + "docker-volume-backup.stop-during-backup", + "service1", + "match", + false, + }, + { + "match does not split", + map[string]string{"docker-volume-backup.stop-during-backup": "service1,service2"}, + "docker-volume-backup.stop-during-backup", + "service1", + "match", + false, + }, + { + "one-of first", + map[string]string{"docker-volume-backup.stop-during-backup": "service1,service2"}, + "docker-volume-backup.stop-during-backup", + "service1", + "one-of", + true, + }, + { + "one-of last with spaces", + map[string]string{"docker-volume-backup.stop-during-backup": "service1, service2"}, + "docker-volume-backup.stop-during-backup", + "service2", + "one-of", + true, + }, + { + "one-of no member", + map[string]string{"docker-volume-backup.stop-during-backup": "service1,service2"}, + "docker-volume-backup.stop-during-backup", + "service3", + "one-of", + false, + }, + { + "one-of single value", + map[string]string{"docker-volume-backup.stop-during-backup": "true"}, + "docker-volume-backup.stop-during-backup", + "true", + "one-of", + true, + }, + { + "label absent", + map[string]string{}, + "docker-volume-backup.stop-during-backup", + "true", + "one-of", + false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := hasLabel(test.labels, test.key, test.value, test.matchBehavior) + if result != test.expected { + t.Errorf("Expected %v, got %v", test.expected, result) + } + }) + } +} + func TestIsSwarm(t *testing.T) { tests := []struct { name string diff --git a/docs/how-tos/stop-containers-during-backup.md b/docs/how-tos/stop-containers-during-backup.md index e16eb7ec..c33dbd0b 100644 --- a/docs/how-tos/stop-containers-during-backup.md +++ b/docs/how-tos/stop-containers-during-backup.md @@ -35,6 +35,47 @@ volumes: data: ``` +## Target a single container from multiple backup instances + +A Docker label can only hold a single value per key, so a container can carry +only one `docker-volume-backup.stop-during-backup` value. If you run multiple +instances of this image with different `BACKUP_STOP_DURING_BACKUP_LABEL` values +and want the same container to be stopped by more than one of them, set +`BACKUP_LABEL_MATCH_BEHAVIOR` to `one-of`. The container's label value is then split on +commas and matches if any of the entries equals the configured value. + +```yml +services: + app: + # definition for app ... + labels: + - docker-volume-backup.stop-during-backup=service1,service2 + + backup-one: + image: offen/docker-volume-backup:v2 + environment: + BACKUP_STOP_DURING_BACKUP_LABEL: service1 + BACKUP_LABEL_MATCH_BEHAVIOR: one-of + volumes: + - data:/backup/my-app-backup:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + + backup-two: + image: offen/docker-volume-backup:v2 + environment: + BACKUP_STOP_DURING_BACKUP_LABEL: service2 + BACKUP_LABEL_MATCH_BEHAVIOR: one-of + volumes: + - data:/backup/my-app-backup:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + +volumes: + data: +``` + +The default value `match` keeps the previous behavior and requires the label +value to be equal to the configured value. + ## Stop containers during backup without restarting Sometimes you might want to stop containers for the backup but not have them start again automatically, for example if they are normally started by an external process or scheduler. diff --git a/docs/reference/index.md b/docs/reference/index.md index 32b47385..9ea2ec80 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -555,6 +555,14 @@ If you need to confirm what the container actually loaded, see [Show loaded conf # skips restarting the container or service once the backup has finished. # BACKUP_STOP_DURING_BACKUP_NO_RESTART_LABEL="true" +# Controls how a container's stop-during-backup label value is matched against +# the value configured above. The default "match" requires the values to be +# equal. Setting this to "one-of" splits the container's label value on commas, +# so a single container labeled `stop-during-backup=service1,service2` can be +# stopped by multiple instances of this image, each configured with a different +# value. +# BACKUP_LABEL_MATCH_BEHAVIOR="match" + # 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