// Copyright 2024 - offen.software // 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) } }