package update import ( "bytes" "errors" "fmt" "os" "path/filepath" "time" "github.com/rs/zerolog/log" ) // RunnerOptions is options provided for the update runner. type RunnerOptions struct { // CheckInterval is the interval to check for updates. CheckInterval time.Duration // Targets is the names of the artifacts to watch for updates. Targets map[string]string } // Runner is a specialized runner for the updater. It is designed with Execute and // Interrupt functions to be compatible with oklog/run. type Runner struct { client *Updater opt RunnerOptions cancel chan struct{} hashCache map[string][]byte } // NewRunner creates a new runner with the provided options. The runner must be // started with Execute. func NewRunner(client *Updater, opt RunnerOptions) (*Runner, error) { if opt.CheckInterval <= 0 { return nil, errors.New("Runner must be configured with interval greater than 0") } if len(opt.Targets) == 0 { return nil, errors.New("Runner must have nonempty subscriptions") } // Initialize hash cache cache := make(map[string][]byte) for target, channel := range opt.Targets { meta, err := client.Lookup(target, channel) if err != nil { return nil, fmt.Errorf("initialize update cache: %w", err) } _, hash, err := selectHashFunction(meta) if err != nil { return nil, fmt.Errorf("select hash for cache: %w", err) } cache[target] = hash } return &Runner{ client: client, opt: opt, // chan gets capacity of 1 so we don't end up hung if Interrupt is // called after Execute has already returned. cancel: make(chan struct{}, 1), hashCache: cache, }, nil } // Execute begins a loop checking for updates. func (r *Runner) Execute() error { log.Debug().Msg("start updater") ticker := time.NewTicker(r.opt.CheckInterval) defer ticker.Stop() // Run until cancel or returning an error for { select { case <-r.cancel: return nil case <-ticker.C: // On each tick, check for updates didUpdate, err := r.updateAction() if err != nil { log.Info().Err(err).Msg("update failed") } if didUpdate { log.Info().Msg("exiting due to successful update") return nil } } } } func (r *Runner) updateAction() (bool, error) { var didUpdate bool if err := r.client.UpdateMetadata(); err != nil { // Consider this a non-fatal error since it will be common to be offline // or otherwise unable to retrieve the metadata. return didUpdate, fmt.Errorf("update metadata: %w", err) } for target, channel := range r.opt.Targets { meta, err := r.client.Lookup(target, channel) if err != nil { return didUpdate, fmt.Errorf("lookup failed: %w", err) } // Check whether the hash has changed _, hash, err := selectHashFunction(meta) if err != nil { return didUpdate, fmt.Errorf("select hash for cache: %w", err) } if !bytes.Equal(r.hashCache[target], hash) { // Update detected log.Info().Str("target", target).Str("channel", channel).Msg("update detected") if err := r.updateTarget(target, channel); err != nil { return didUpdate, fmt.Errorf("update %s@%s: %w", target, channel, err) } log.Info().Str("target", target).Str("channel", channel).Msg("update completed") didUpdate = true } else { log.Debug().Str("target", target).Str("channel", channel).Msg("no update") } } return didUpdate, nil } func (r *Runner) updateTarget(target, channel string) error { path, err := r.client.Get(target, channel) if err != nil { return fmt.Errorf("get binary: %w", err) } if target != "orbit" { return nil } // Symlink Orbit binary linkPath := filepath.Join(r.client.opt.RootDirectory, "bin", "orbit", filepath.Base(path)) // Rename the old file otherwise overwrite fails if err := os.Rename(linkPath, linkPath+".old"); err != nil { return fmt.Errorf("move old symlink current: %w", err) } if err := os.Symlink(path, linkPath); err != nil { return fmt.Errorf("symlink current: %w", err) } return nil } func (r *Runner) Interrupt(err error) { r.cancel <- struct{}{} log.Debug().Err(err).Msg("interrupt updater") }