diff --git a/cmd/backup/config.go b/cmd/backup/config.go index 692214da..460419b7 100644 --- a/cmd/backup/config.go +++ b/cmd/backup/config.go @@ -48,6 +48,8 @@ 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"` + BackupLabelMatchSeparator string `split_words:"true" default:","` BackupStopServiceTimeout time.Duration `split_words:"true" default:"5m"` BackupFromSnapshot bool `split_words:"true"` BackupExcludeRegexp RegexpDecoder `split_words:"true"` @@ -120,6 +122,28 @@ 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 BACKUP_LABEL_MATCH_SEPARATOR (a comma by default) 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..733214d2 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,28 @@ 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, separator string) bool { val, ok := labels[key] - return ok && val == value + if !ok { + return false + } + if matchBehavior == "one-of" { + if separator == "" { + separator = "," + } + for _, candidate := range strings.Split(val, separator) { + 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, separator string) (bool, bool, error) { + hasStopDuringBackupLabel := hasLabel(labels, "docker-volume-backup.stop-during-backup", stopDuringBackupLabelValue, matchBehavior, separator) + hasStopDuringBackupNoRestartLabel := hasLabel(labels, "docker-volume-backup.stop-during-backup-no-restart", stopDuringBackupNoRestartLabelValue, matchBehavior, separator) 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 +144,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, s.c.BackupLabelMatchSeparator) if err != nil { return noop, errwrap.Wrap(err, "error querying for containers to stop") } @@ -154,7 +169,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, s.c.BackupLabelMatchSeparator) 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..d9e97989 100644 --- a/cmd/backup/stop_restart_test.go +++ b/cmd/backup/stop_restart_test.go @@ -19,6 +19,118 @@ 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 + separator string + 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, + }, + { + "one-of custom separator", + map[string]string{"docker-volume-backup.stop-during-backup": "a,b|c,d"}, + "docker-volume-backup.stop-during-backup", + "c,d", + "one-of", + "|", + true, + }, + { + "one-of empty separator falls back to comma", + map[string]string{"docker-volume-backup.stop-during-backup": "service1,service2"}, + "docker-volume-backup.stop-during-backup", + "service2", + "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, test.separator) + 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..0ef805cc 100644 --- a/docs/how-tos/stop-containers-during-backup.md +++ b/docs/how-tos/stop-containers-during-backup.md @@ -35,6 +35,49 @@ 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. If your values +themselves contain a comma, set `BACKUP_LABEL_MATCH_SEPARATOR` to a different character +to split on instead. + +```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..4889d735 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -555,6 +555,19 @@ 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 +# BACKUP_LABEL_MATCH_SEPARATOR, 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" + +# The separator used to split a container's label value when +# BACKUP_LABEL_MATCH_BEHAVIOR is "one-of". Defaults to a comma. Set it to a +# different character if your label values themselves contain a comma. +# BACKUP_LABEL_MATCH_SEPARATOR="," + # 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