From 072f807b9cae3443a3d6f46d94bfe6e8526f750f Mon Sep 17 00:00:00 2001 From: Avi Deitcher Date: Wed, 9 Sep 2026 16:11:48 +0300 Subject: [PATCH 1/6] accept explicit timezone offset and Z UTC in begin Signed-off-by: Avi Deitcher --- cmd/dump.go | 26 ++++++++++++++++++++++---- cmd/dump_test.go | 42 ++++++++++++++++++++++++++++++++++++++++++ cmd/prune.go | 7 +++++-- 3 files changed, 69 insertions(+), 6 deletions(-) diff --git a/cmd/dump.go b/cmd/dump.go index 41d1c8ba..65b4442f 100644 --- a/cmd/dump.go +++ b/cmd/dump.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "strings" + "time" "github.com/google/uuid" "github.com/spf13/cobra" @@ -240,7 +241,10 @@ func dumpCmd(passedExecs execs, cmdConfig *cmdConfiguration) (*cobra.Command, er } // timer options - timerOpts := parseTimerOptions(v, cmdConfig.configuration) + timerOpts, err := parseTimerOptions(v, cmdConfig.configuration) + if err != nil { + return err + } var executor execs executor = &core.Executor{} @@ -391,7 +395,7 @@ S3: If it is a URL of the format s3://bucketname/path then it will connect via S flags.Int("frequency", defaultFrequency, "how often to run backups, in minutes") // begin - flags.String("begin", defaultBegin, "What time to do the first dump. Must be in one of two formats: Absolute: HHMM, e.g. `2330` or `0415`; or Relative: +MM, i.e. how many minutes after starting the container, e.g. `+0` (immediate), `+10` (in 10 minutes), or `+90` in an hour and a half") + flags.String("begin", defaultBegin, "What time to do the first dump. Absolute times may be UTC (`0400` or `0400Z`) or include a UTC offset (`0400+08:00`). A zoneless time is interpreted as UTC. Relative times use +MM, i.e. minutes after starting the container, such as `+0`, `+10`, or `+90`") // cron flags.String("cron", "", "Set the dump schedule using standard [crontab syntax](https://en.wikipedia.org/wiki/Cron), a single line.") @@ -444,7 +448,7 @@ S3: If it is a URL of the format s3://bucketname/path then it will connect via S return cmd, nil } -func parseTimerOptions(v *viper.Viper, config *api.ConfigSpec) core.TimerOptions { +func parseTimerOptions(v *viper.Viper, config *api.ConfigSpec) (core.TimerOptions, error) { var scheduleConfig *api.Schedule if config != nil { dumpConfig := config.Dump @@ -464,6 +468,20 @@ func parseTimerOptions(v *viper.Viper, config *api.ConfigSpec) core.TimerOptions if begin == "" && scheduleConfig != nil && scheduleConfig.Begin != nil { begin = fmt.Sprintf("%d", *scheduleConfig.Begin) } + if begin != "" && !strings.HasPrefix(begin, "+") { + var parsed time.Time + var err error + if len(begin) == 4 { + // Preserve the legacy behavior: an absolute time without a zone is UTC. + parsed, err = time.Parse("1504", begin) + } else { + parsed, err = time.Parse("1504Z07:00", begin) + } + if err != nil { + return core.TimerOptions{}, fmt.Errorf("invalid begin option %q: %w", begin, err) + } + begin = parsed.UTC().Format("1504") + } frequency := v.GetInt("frequency") if frequency == 0 && scheduleConfig != nil && scheduleConfig.Frequency != nil { frequency = *scheduleConfig.Frequency @@ -473,7 +491,7 @@ func parseTimerOptions(v *viper.Viper, config *api.ConfigSpec) core.TimerOptions Cron: cron, Begin: begin, Frequency: frequency, - } + }, nil } diff --git a/cmd/dump_test.go b/cmd/dump_test.go index f831d0cd..ede15b41 100644 --- a/cmd/dump_test.go +++ b/cmd/dump_test.go @@ -11,9 +11,51 @@ import ( "github.com/databacker/mysql-backup/pkg/storage" "github.com/databacker/mysql-backup/pkg/storage/file" "github.com/go-test/deep" + "github.com/spf13/viper" "github.com/stretchr/testify/mock" ) +func TestParseTimerOptionsBegin(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + begin string + expected string + wantError bool + }{ + {name: "relative time", begin: "+25", expected: "+25"}, + {name: "legacy implicit UTC", begin: "0400", expected: "0400"}, + {name: "explicit UTC", begin: "0400Z", expected: "0400"}, + {name: "positive offset crossing midnight", begin: "0400+08:00", expected: "2000"}, + {name: "negative offset", begin: "0400-05:30", expected: "0930"}, + {name: "invalid hour", begin: "2500Z", wantError: true}, + {name: "invalid offset", begin: "0400+25:00", wantError: true}, + {name: "invalid suffix", begin: "0400UTC", wantError: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v := viper.New() + v.Set("begin", tt.begin) + + options, err := parseTimerOptions(v, nil) + if tt.wantError { + if err == nil { + t.Fatalf("parseTimerOptions() error = nil, want an error") + } + return + } + if err != nil { + t.Fatalf("parseTimerOptions() error = %v", err) + } + if options.Begin != tt.expected { + t.Errorf("parseTimerOptions() Begin = %q, want %q", options.Begin, tt.expected) + } + }) + } +} + func TestDumpCmd(t *testing.T) { t.Parallel() diff --git a/cmd/prune.go b/cmd/prune.go index dda99879..037fe776 100644 --- a/cmd/prune.go +++ b/cmd/prune.go @@ -59,7 +59,10 @@ func pruneCmd(passedExecs execs, cmdConfig *cmdConfiguration) (*cobra.Command, e } // timer options - timerOpts := parseTimerOptions(v, cmdConfig.configuration) + timerOpts, err := parseTimerOptions(v, cmdConfig.configuration) + if err != nil { + return err + } var executor execs executor = &core.Executor{} @@ -96,7 +99,7 @@ func pruneCmd(passedExecs execs, cmdConfig *cmdConfiguration) (*cobra.Command, e flags.Int("frequency", defaultFrequency, "how often to run prunes, in minutes") // begin - flags.String("begin", defaultBegin, "What time to do the first prune. Must be in one of two formats: Absolute: HHMM, e.g. `2330` or `0415`; or Relative: +MM, i.e. how many minutes after starting the container, e.g. `+0` (immediate), `+10` (in 10 minutes), or `+90` in an hour and a half") + flags.String("begin", defaultBegin, "What time to do the first prune. Absolute times may be UTC (`0400` or `0400Z`) or include a UTC offset (`0400+08:00`). A zoneless time is interpreted as UTC. Relative times use +MM, i.e. minutes after starting the container, such as `+0`, `+10`, or `+90`") // cron flags.String("cron", "", "Set the prune schedule using standard [crontab syntax](https://en.wikipedia.org/wiki/Cron), a single line.") From 96ef77d8defa73195179300f4300fd558b045b32 Mon Sep 17 00:00:00 2001 From: Avi Deitcher Date: Wed, 9 Sep 2026 16:21:47 +0300 Subject: [PATCH 2/6] extract common flags from CLI Signed-off-by: Avi Deitcher --- cmd/dump.go | 12 +----------- cmd/flags.go | 17 +++++++++++++++++ cmd/prune.go | 12 +----------- 3 files changed, 19 insertions(+), 22 deletions(-) create mode 100644 cmd/flags.go diff --git a/cmd/dump.go b/cmd/dump.go index 65b4442f..9f30bf78 100644 --- a/cmd/dump.go +++ b/cmd/dump.go @@ -391,17 +391,7 @@ S3: If it is a URL of the format s3://bucketname/path then it will connect via S // skip extended insert in dump; instead, one INSERT per record in each table flags.Bool("skip-extended-insert", false, "Skip extended insert in dump; instead, one INSERT per record in each table.") - // frequency - flags.Int("frequency", defaultFrequency, "how often to run backups, in minutes") - - // begin - flags.String("begin", defaultBegin, "What time to do the first dump. Absolute times may be UTC (`0400` or `0400Z`) or include a UTC offset (`0400+08:00`). A zoneless time is interpreted as UTC. Relative times use +MM, i.e. minutes after starting the container, such as `+0`, `+10`, or `+90`") - - // cron - flags.String("cron", "", "Set the dump schedule using standard [crontab syntax](https://en.wikipedia.org/wiki/Cron), a single line.") - - // once - flags.Bool("once", false, "Override all other settings and run the dump once immediately and exit. Useful if you use an external scheduler (e.g. as part of an orchestration solution like Cattle or Docker Swarm or [kubernetes cron jobs](https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/)) and don't want the container to do the scheduling internally.") + addTimerFlags(flags) // parallelism - how many databases (and therefore connections) to back up at once flags.Int("parallelism", 1, "How many databases to back up in parallel.") diff --git a/cmd/flags.go b/cmd/flags.go new file mode 100644 index 00000000..b60374ba --- /dev/null +++ b/cmd/flags.go @@ -0,0 +1,17 @@ +package cmd + +import "github.com/spf13/pflag" + +func addTimerFlags(flags *pflag.FlagSet) { + // frequency + flags.Int("frequency", defaultFrequency, "how often to run, in minutes") + + // begin + flags.String("begin", defaultBegin, "What time to do the first run. Absolute times may be UTC (`0400` or `0400Z`) or include a UTC offset (`0400+08:00`). A zoneless time is interpreted as UTC. Relative times use +MM, i.e. minutes after starting the container, such as `+0`, `+10`, or `+90`") + + // cron + flags.String("cron", "", "Set the run schedule using standard [crontab syntax](https://en.wikipedia.org/wiki/Cron), a single line.") + + // once + flags.Bool("once", false, "Override all other settings and run once immediately and exit. Useful if you use an external scheduler (e.g. as part of an orchestration solution like Cattle or Docker Swarm or [kubernetes cron jobs](https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/)) and don't want the container to do the scheduling internally.") +} diff --git a/cmd/prune.go b/cmd/prune.go index 037fe776..0d0a3794 100644 --- a/cmd/prune.go +++ b/cmd/prune.go @@ -95,17 +95,7 @@ func pruneCmd(passedExecs execs, cmdConfig *cmdConfiguration) (*cobra.Command, e // retention flags.String("retention", "", "Retention period for backups. REQUIRED. Can be number of backups or time-based. For time-based, the format is: 1d, 1w, 1m, 1y for days, weeks, months, years, respectively. For number-based, the format is: 1c, 2c, 3c, etc. for the count of backups to keep.") - // frequency - flags.Int("frequency", defaultFrequency, "how often to run prunes, in minutes") - - // begin - flags.String("begin", defaultBegin, "What time to do the first prune. Absolute times may be UTC (`0400` or `0400Z`) or include a UTC offset (`0400+08:00`). A zoneless time is interpreted as UTC. Relative times use +MM, i.e. minutes after starting the container, such as `+0`, `+10`, or `+90`") - - // cron - flags.String("cron", "", "Set the prune schedule using standard [crontab syntax](https://en.wikipedia.org/wiki/Cron), a single line.") - - // once - flags.Bool("once", false, "Override all other settings and run the prune once immediately and exit. Useful if you use an external scheduler (e.g. as part of an orchestration solution like Cattle or Docker Swarm or [kubernetes cron jobs](https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/)) and don't want the container to do the scheduling internally.") + addTimerFlags(flags) return cmd, nil } From 0043e2c90eef067519061d515db137caf79ccc6f Mon Sep 17 00:00:00 2001 From: Avi Deitcher Date: Wed, 9 Sep 2026 16:25:28 +0300 Subject: [PATCH 3/6] more explicit comments on BEGIN times Signed-off-by: Avi Deitcher --- cmd/flags.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/flags.go b/cmd/flags.go index b60374ba..c9eb00ab 100644 --- a/cmd/flags.go +++ b/cmd/flags.go @@ -7,7 +7,7 @@ func addTimerFlags(flags *pflag.FlagSet) { flags.Int("frequency", defaultFrequency, "how often to run, in minutes") // begin - flags.String("begin", defaultBegin, "What time to do the first run. Absolute times may be UTC (`0400` or `0400Z`) or include a UTC offset (`0400+08:00`). A zoneless time is interpreted as UTC. Relative times use +MM, i.e. minutes after starting the container, such as `+0`, `+10`, or `+90`") + flags.String("begin", defaultBegin, "What time to do the first run, as absolute or relative time. Absolute times may be UTC (`0400Z`) or include a UTC offset (`0400+08:00`). Relative times use +MM, i.e. minutes after starting the container, such as `+0`, `+10`, or `+90`. A zoneless time (`0400`) is legacy and should not be used, but is interpreted as UTC.") // cron flags.String("cron", "", "Set the run schedule using standard [crontab syntax](https://en.wikipedia.org/wiki/Cron), a single line.") From 04dcc4d4f0d6d462822b287670a5d871f6b00e5f Mon Sep 17 00:00:00 2001 From: Avi Deitcher Date: Wed, 9 Sep 2026 16:39:57 +0300 Subject: [PATCH 4/6] add support for local timezone Signed-off-by: Avi Deitcher --- cmd/dump.go | 12 +++++++++++- cmd/dump_test.go | 22 ++++++++++++++++++++++ cmd/flags.go | 2 +- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/cmd/dump.go b/cmd/dump.go index 9f30bf78..01d2ede3 100644 --- a/cmd/dump.go +++ b/cmd/dump.go @@ -461,7 +461,17 @@ func parseTimerOptions(v *viper.Viper, config *api.ConfigSpec) (core.TimerOption if begin != "" && !strings.HasPrefix(begin, "+") { var parsed time.Time var err error - if len(begin) == 4 { + if strings.HasSuffix(begin, "@local") { + parsed, err = time.Parse("1504", strings.TrimSuffix(begin, "@local")) + if err == nil { + now := time.Now().In(time.Local) + local := time.Date(now.Year(), now.Month(), now.Day(), parsed.Hour(), parsed.Minute(), 0, 0, time.Local) + if !local.After(now) { + local = local.AddDate(0, 0, 1) + } + parsed = local + } + } else if len(begin) == 4 { // Preserve the legacy behavior: an absolute time without a zone is UTC. parsed, err = time.Parse("1504", begin) } else { diff --git a/cmd/dump_test.go b/cmd/dump_test.go index ede15b41..949d2e3a 100644 --- a/cmd/dump_test.go +++ b/cmd/dump_test.go @@ -4,6 +4,7 @@ import ( "io" "net/url" "testing" + "time" "github.com/databacker/mysql-backup/pkg/compression" "github.com/databacker/mysql-backup/pkg/core" @@ -31,6 +32,7 @@ func TestParseTimerOptionsBegin(t *testing.T) { {name: "negative offset", begin: "0400-05:30", expected: "0930"}, {name: "invalid hour", begin: "2500Z", wantError: true}, {name: "invalid offset", begin: "0400+25:00", wantError: true}, + {name: "invalid local time", begin: "2500@local", wantError: true}, {name: "invalid suffix", begin: "0400UTC", wantError: true}, } @@ -56,6 +58,26 @@ func TestParseTimerOptionsBegin(t *testing.T) { } } +func TestParseTimerOptionsLocalBegin(t *testing.T) { + t.Parallel() + + now := time.Now().In(time.Local) + local := time.Date(now.Year(), now.Month(), now.Day(), 4, 0, 0, 0, time.Local) + if !local.After(now) { + local = local.AddDate(0, 0, 1) + } + + v := viper.New() + v.Set("begin", "0400@local") + options, err := parseTimerOptions(v, nil) + if err != nil { + t.Fatalf("parseTimerOptions() error = %v", err) + } + if expected := local.UTC().Format("1504"); options.Begin != expected { + t.Errorf("parseTimerOptions() Begin = %q, want %q", options.Begin, expected) + } +} + func TestDumpCmd(t *testing.T) { t.Parallel() diff --git a/cmd/flags.go b/cmd/flags.go index c9eb00ab..2f47a00c 100644 --- a/cmd/flags.go +++ b/cmd/flags.go @@ -7,7 +7,7 @@ func addTimerFlags(flags *pflag.FlagSet) { flags.Int("frequency", defaultFrequency, "how often to run, in minutes") // begin - flags.String("begin", defaultBegin, "What time to do the first run, as absolute or relative time. Absolute times may be UTC (`0400Z`) or include a UTC offset (`0400+08:00`). Relative times use +MM, i.e. minutes after starting the container, such as `+0`, `+10`, or `+90`. A zoneless time (`0400`) is legacy and should not be used, but is interpreted as UTC.") + flags.String("begin", defaultBegin, "What time to do the first run, as absolute or relative time. Absolute times may be UTC (`0400Z`), include a UTC offset (`0400+08:00`), or use the platform's local timezone (`0400@local`). Relative times use +MM, i.e. minutes after starting the run, such as `+0`, `+10`, or `+90`. A zoneless time (`0400`) is legacy and should not be used, but is interpreted as UTC.") // cron flags.String("cron", "", "Set the run schedule using standard [crontab syntax](https://en.wikipedia.org/wiki/Cron), a single line.") From cb7e1803fbb24db3063e910011ff2d142c3a6db7 Mon Sep 17 00:00:00 2001 From: Avi Deitcher Date: Wed, 9 Sep 2026 17:00:07 +0300 Subject: [PATCH 5/6] add support for explicit timezones Signed-off-by: Avi Deitcher --- cmd/dump.go | 68 ++++++++++++++++++++++++++++++++++++++++-------- cmd/dump_test.go | 29 +++++++++++++++++++++ cmd/flags.go | 2 +- 3 files changed, 87 insertions(+), 12 deletions(-) diff --git a/cmd/dump.go b/cmd/dump.go index 01d2ede3..b11cf210 100644 --- a/cmd/dump.go +++ b/cmd/dump.go @@ -7,6 +7,7 @@ import ( "os" "strings" "time" + _ "time/tzdata" "github.com/google/uuid" "github.com/spf13/cobra" @@ -461,20 +462,65 @@ func parseTimerOptions(v *viper.Viper, config *api.ConfigSpec) (core.TimerOption if begin != "" && !strings.HasPrefix(begin, "+") { var parsed time.Time var err error - if strings.HasSuffix(begin, "@local") { - parsed, err = time.Parse("1504", strings.TrimSuffix(begin, "@local")) - if err == nil { - now := time.Now().In(time.Local) - local := time.Date(now.Year(), now.Month(), now.Day(), parsed.Hour(), parsed.Minute(), 0, 0, time.Local) - if !local.After(now) { - local = local.AddDate(0, 0, 1) + clock, zoneName, hasZoneName := strings.Cut(begin, "@") + switch { + case hasZoneName: + parsed, err = func() (time.Time, error) { + clockTime, err := time.Parse("1504", clock) + if err != nil { + return time.Time{}, err } - parsed = local - } - } else if len(begin) == 4 { + + var location *time.Location + switch zoneName { + case "local": + location = time.Local + default: + location, err = time.LoadLocation(zoneName) + if err != nil { + return time.Time{}, err + } + } + + now := time.Now() + localNow := now.In(location) + requestedHour := clockTime.Hour() + requestedMinuteOfHour := clockTime.Minute() + requestedMinute := requestedHour*60 + requestedMinuteOfHour + currentMinute := localNow.Hour()*60 + localNow.Minute() + + // Search actual instants rather than relying on time.Date so DST + // overlaps select the earliest future occurrence, and DST gaps + // can be detected rather than silently normalized. + findOccurrence := func(year int, month time.Month, day int) (time.Time, bool) { + anchor := time.Date(year, month, day, 12, 0, 0, 0, time.UTC) + for candidate := anchor.Add(-30 * time.Hour); !candidate.After(anchor.Add(30 * time.Hour)); candidate = candidate.Add(time.Minute) { + wall := candidate.In(location) + if wall.Year() == year && wall.Month() == month && wall.Day() == day && + wall.Hour() == requestedHour && wall.Minute() == requestedMinuteOfHour && candidate.After(now) { + return candidate, true + } + } + return time.Time{}, false + } + + if occurrence, found := findOccurrence(localNow.Year(), localNow.Month(), localNow.Day()); found { + return occurrence, nil + } + if requestedMinute > currentMinute { + return time.Time{}, fmt.Errorf("time %s does not exist today in timezone %s", clock, zoneName) + } + + tomorrow := localNow.AddDate(0, 0, 1) + if occurrence, found := findOccurrence(tomorrow.Year(), tomorrow.Month(), tomorrow.Day()); found { + return occurrence, nil + } + return time.Time{}, fmt.Errorf("time %s does not exist tomorrow in timezone %s", clock, zoneName) + }() + case len(begin) == 4: // Preserve the legacy behavior: an absolute time without a zone is UTC. parsed, err = time.Parse("1504", begin) - } else { + default: parsed, err = time.Parse("1504Z07:00", begin) } if err != nil { diff --git a/cmd/dump_test.go b/cmd/dump_test.go index 949d2e3a..20f8530a 100644 --- a/cmd/dump_test.go +++ b/cmd/dump_test.go @@ -33,6 +33,7 @@ func TestParseTimerOptionsBegin(t *testing.T) { {name: "invalid hour", begin: "2500Z", wantError: true}, {name: "invalid offset", begin: "0400+25:00", wantError: true}, {name: "invalid local time", begin: "2500@local", wantError: true}, + {name: "unknown timezone", begin: "0400@Not/A_Real_Zone", wantError: true}, {name: "invalid suffix", begin: "0400UTC", wantError: true}, } @@ -78,6 +79,34 @@ func TestParseTimerOptionsLocalBegin(t *testing.T) { } } +func TestParseTimerOptionsNamedTimezoneBegin(t *testing.T) { + t.Parallel() + + for _, zoneName := range []string{"America/New_York", "Asia/Jerusalem", "Asia/Kathmandu", "Pacific/Kiritimati"} { + t.Run(zoneName, func(t *testing.T) { + location, err := time.LoadLocation(zoneName) + if err != nil { + t.Fatalf("time.LoadLocation(%q) error = %v", zoneName, err) + } + now := time.Now().In(location) + local := time.Date(now.Year(), now.Month(), now.Day(), 4, 0, 0, 0, location) + if !local.After(now) { + local = local.AddDate(0, 0, 1) + } + + v := viper.New() + v.Set("begin", "0400@"+zoneName) + options, err := parseTimerOptions(v, nil) + if err != nil { + t.Fatalf("parseTimerOptions() error = %v", err) + } + if expected := local.UTC().Format("1504"); options.Begin != expected { + t.Errorf("parseTimerOptions() Begin = %q, want %q", options.Begin, expected) + } + }) + } +} + func TestDumpCmd(t *testing.T) { t.Parallel() diff --git a/cmd/flags.go b/cmd/flags.go index 2f47a00c..e3a642fa 100644 --- a/cmd/flags.go +++ b/cmd/flags.go @@ -7,7 +7,7 @@ func addTimerFlags(flags *pflag.FlagSet) { flags.Int("frequency", defaultFrequency, "how often to run, in minutes") // begin - flags.String("begin", defaultBegin, "What time to do the first run, as absolute or relative time. Absolute times may be UTC (`0400Z`), include a UTC offset (`0400+08:00`), or use the platform's local timezone (`0400@local`). Relative times use +MM, i.e. minutes after starting the run, such as `+0`, `+10`, or `+90`. A zoneless time (`0400`) is legacy and should not be used, but is interpreted as UTC.") + flags.String("begin", defaultBegin, "What time to do the first run, as absolute or relative time. Absolute times may be UTC (`0400Z`), include a UTC offset (`0400+08:00`), use the platform's local timezone (`0400@local`), or use an IANA timezone (`0400@America/New_York`). Relative times use +MM, i.e. minutes after starting the run, such as `+0`, `+10`, or `+90`. A zoneless time (`0400`) is legacy and should not be used, but is interpreted as UTC.") // cron flags.String("cron", "", "Set the run schedule using standard [crontab syntax](https://en.wikipedia.org/wiki/Cron), a single line.") From 46c88b91cd0c1c796b17a7c5f6672199dd9a855e Mon Sep 17 00:00:00 2001 From: Avi Deitcher Date: Wed, 9 Sep 2026 17:11:22 +0300 Subject: [PATCH 6/6] document begin options Signed-off-by: Avi Deitcher --- README.md | 12 ++++++------ docs/backup.md | 4 ++-- docs/configuration.md | 4 ++-- docs/scheduling.md | 21 +++++++++++++++++++++ examples/configs/local.yaml | 2 +- 5 files changed, 32 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 231e6f76..cf29b9c9 100644 --- a/README.md +++ b/README.md @@ -49,15 +49,15 @@ To run a backup, launch `mysql-backup` - as a container or as a binary - with th For example: ````bash -docker run -d --restart=always -e DB_DUMP_FREQUENCY=60 -e DB_DUMP_BEGIN=2330 -e DB_DUMP_TARGET=/local/file/path -e DB_SERVER=my-db-address -v /local/file/path:/db databack/mysql-backup dump +docker run -d --restart=always -e DB_DUMP_FREQUENCY=60 -e DB_DUMP_BEGIN=2330Z -e DB_DUMP_TARGET=/local/file/path -e DB_SERVER=my-db-address -v /local/file/path:/db databack/mysql-backup dump # or -mysql-backup dump --frequency=60 --begin=2330 --target=/local/file/path --server=my-db-address +mysql-backup dump --frequency=60 --begin=2330Z --target=/local/file/path --server=my-db-address # or to connect to a local mysqld via the unix domain socket as the current user -mysql-backup dump --frequency=60 --begin=2330 --target=/local/file/path --server=/run/mysqld/mysqld.sock +mysql-backup dump --frequency=60 --begin=2330Z --target=/local/file/path --server=/run/mysqld/mysqld.sock ```` Or `mysql-backup --config-file=/path/to/config/file.yaml` where `/path/to/config/file.yaml` is a file @@ -71,14 +71,14 @@ dump: target: /local/file/path ``` -The above will run a dump every 60 minutes, beginning at the next 2330 local time, from the database accessible in the container `my-db-address`. +The command and environment-variable examples run a dump every 60 minutes, beginning at the next 23:30 local time. The config-file example uses the legacy zoneless form, which is interpreted as UTC. ````bash -docker run -d --restart=always -e DB_USER=user123 -e DB_PASS=pass123 -e DB_DUMP_FREQUENCY=60 -e DB_DUMP_BEGIN=2330 -e DB_DUMP_TARGET=/db -e DB_SERVER=my-db-address -v /local/file/path:/db databack/mysql-backup dump +docker run -d --restart=always -e DB_USER=user123 -e DB_PASS=pass123 -e DB_DUMP_FREQUENCY=60 -e DB_DUMP_BEGIN=2330Z -e DB_DUMP_TARGET=/db -e DB_SERVER=my-db-address -v /local/file/path:/db databack/mysql-backup dump # or -mysql-backup dump --user=user123 --pass=pass123 --frequency=60 --begin=2330 --target=/local/file/path --server=my-db-address --port=3306 +mysql-backup dump --user=user123 --pass=pass123 --frequency=60 --begin=2330Z --target=/local/file/path --server=my-db-address --port=3306 ```` See [backup](./docs/backup.md) for a more detailed description of performing backups. diff --git a/docs/backup.md b/docs/backup.md index 0b96804d..b258a2d0 100644 --- a/docs/backup.md +++ b/docs/backup.md @@ -321,7 +321,7 @@ mysql-backup dump --pre-backup-scripts=/path/to/pre-backup/scripts --post-backup ```bash docker run -d --restart=always -e DB_USER=user123 -e DB_PASS=pass123 -e DB_DUMP_FREQUENCY=60 \ - -e DB_DUMP_BEGIN=2330 -e DB_DUMP_TARGET=/db -e DB_SERVER=my-db-container:db \ + -e DB_DUMP_BEGIN=2330Z -e DB_DUMP_TARGET=/db -e DB_SERVER=my-db-container:db \ -v /path/to/pre-backup/scripts:/scripts.d/pre-backup \ -v /path/to/post-backup/scripts:/scripts.d/post-backup \ -v /local/file/path:/db \ @@ -345,7 +345,7 @@ services: - DB_USER=user123 - DB_PASS=pass123 - DB_DUMP_FREQUENCY=60 - - DB_DUMP_BEGIN=2330 + - DB_DUMP_BEGIN=2330Z - DB_SERVER=mysql_db command: dump mysql_db: diff --git a/docs/configuration.md b/docs/configuration.md index 3e6f24dd..1350665c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -75,7 +75,7 @@ The following are the environment variables, CLI flags and configuration file op | Replace single long INSERT statement per table with one INSERT statement per line | B | `skip-extended-insert` | `DB_DUMP_SKIP_EXTENDED_INSERT` | `dump.skipExtendedInsert` | `false` | | restore to a specific database | R | `restore --database` | `RESTORE_DATABASE` | `restore.database` | | | how often to do a dump or prune, in minutes | BP | `dump --frequency` | `DB_DUMP_FREQUENCY` | `dump.schedule.frequency` | `1440` (in minutes), i.e. once per day | -| what time to do the first dump or prune | BP | `dump --begin` | `DB_DUMP_BEGIN` | `dump.schedule.begin` | `0`, i.e. immediately | +| what time to do the first dump or prune; see [scheduling](./scheduling.md#frequency-and-delayed-start) | BP | `dump --begin` | `DB_DUMP_BEGIN` | `dump.schedule.begin` | `+0`, i.e. immediately | | cron schedule for dumps or prunes | BP | `dump --cron` | `DB_DUMP_CRON` | `dump.schedule.cron` | | | run the backup or prune a single time and exit | BP | `dump --once` | `DB_DUMP_ONCE` | `dump.schedule.once` | `false` | | enable debug logging | BRP | `debug` | `DB_DEBUG` | `logging` | `false` | @@ -135,7 +135,7 @@ for details of each. * `noDatabaseName`: boolean, remove `USE ` from dumpfile * `schedule`: the schedule configuration * `frequency`: int, the frequency of the schedule in minutes - * `begin`: int, the time to begin the schedule in minutes from start of process + * `begin`: int, the time to begin the schedule in minutes from start of process. The CLI flag and environment variable also accept the absolute-time formats described in [scheduling](./scheduling.md#frequency-and-delayed-start). * `cron`: string, the cron schedule * `once`: boolean, run once and exit * `compression`: string, the compression to use diff --git a/docs/scheduling.md b/docs/scheduling.md index c5e06668..21e8403b 100644 --- a/docs/scheduling.md +++ b/docs/scheduling.md @@ -82,6 +82,17 @@ The frequency value is in minutes. Thus, you can set backup to run every hour by For a relative delayed start, prefix the number of minutes with `+`; for example, `+120` delays the first backup by 2 hours. +An absolute delayed start uses a four-digit 24-hour time followed by its timezone: + +* `0400Z` means 04:00 UTC. +* `0400+08:00` means 04:00 at a fixed UTC+08:00 offset. +* `0400@local` means 04:00 in the timezone of the computer or container. The local timezone is + obtained from the `TZ` environment variable or the platform timezone configuration. +* `0400@America/New_York` means 04:00 in the named IANA timezone, including daylight-saving rules. + +A zoneless value such as `0400` continues to mean UTC for compatibility with existing deployments, +but is considered legacy. Prefer an explicit `Z`, offset, `@local`, or IANA timezone. + You can set the frequency start via: * Environment variable: `DB_DUMP_FREQUENCY=60` @@ -103,3 +114,13 @@ dump: schedule: begin: "+120" ``` + +For example, to begin at the next 04:00 in New York: + +```bash +mysql-backup dump --frequency=1440 --begin=0400@America/New_York +``` + +`begin` determines the first run. Later runs use the configured frequency as an elapsed number of +minutes. Consequently, a frequency of 1440 may shift by one local hour after a daylight-saving +transition; use a timezone-aware cron schedule when every run must remain at the same local time. diff --git a/examples/configs/local.yaml b/examples/configs/local.yaml index 4c9b05b6..3c445cab 100644 --- a/examples/configs/local.yaml +++ b/examples/configs/local.yaml @@ -26,7 +26,7 @@ spec: once: true # run only once and exit; ignores all other scheduling. Defaults to false cron: "0 10 * * *" frequency: 1440 # in minutes - begin: +25 # What time to do the first dump. Must be in one of two formats: Absolute: HHMM, e.g. `2330` or `0415`; or Relative: +MM, i.e. how many minutes after starting the container, e.g. `+0` (immediate), `+10` (in 10 minutes), or `+90` in an hour and a half" + begin: +25 # Minutes after startup. CLI/env also accept absolute times such as 0400Z, 0400+08:00, 0400@local, or 0400@America/New_York. compression: gzip # defaults to gzip compact: true # defaults to false maxAllowedPacket: 4194304 # defaults to 4194304