package restic import ( "bufio" "encoding/json" "errors" "fmt" "github.com/rs/zerolog/log" "github.com/skratchdot/open-golang/open" "os" "os/exec" "path/filepath" "time" ) type ResticSnapshot struct { Time time.Time `json:"time"` // format 2023-03-01T16:15:34.111513+01:00 Tree string `json:"tree"` Paths []string `json:"paths"` Id string `json:"short_id"` } func (rs ResticSnapshot) ShortTime() string { return rs.Time.Format(ShortTimeFormat) } type Wrapper struct { LatestSnapshots []ResticSnapshot mountCommand *exec.Cmd } var ResticCmdTimeoutError = errors.New("backup output timeout, repository server down?") func (w *Wrapper) HasSnapshots() bool { return len(w.LatestSnapshots) > 0 } func (w *Wrapper) LastSnapshot() ResticSnapshot { if !w.HasSnapshots() { return ResticSnapshot{ Id: "(no snapshots yet)", Time: time.Time{}, } } return w.LatestSnapshots[len(w.LatestSnapshots)-1] } // Cleanup should be called on Quit and terminates trailing commands. // Note that https://pkg.go.dev/os/signal should be used (and possibly Prctl) to control death signal of 'forked' when exec.Command() is used // Otherwise restic commands could outlive restictray's. Ignore this for now and only kill a possible trailing mount cmd func (w *Wrapper) Cleanup() { if w.mountCommand != nil && w.mountCommand.Process != nil { // Could be killed or terminated due to manual unmount, ignore errors and retry anyway w.mountCommand.Process.Kill() // Sometimes not correctly unmounted causing consecutive open attempts of mounted folder to fail exec.Command("umount", MountDir()).Run() } } func resticCmdFn(args ...string) *exec.Cmd { // Running from GoLand: could be /private/var/folders/5s/csgpcjlx1wg9659_485vqz880000gn/T/GoLand/restictray // Installed: could be /Applications/restictray/restictray.app/Contents/MacOS/restictray resticExec := filepath.Join(filepath.Dir(executable), "restic") if _, err := os.Stat(resticExec); os.IsNotExist(err) { resticExec = "restic" // can't find embedded exec, assume in $PATH } cmd := exec.Command(resticExec, args...) log.Debug().Msg(cmd.String()) return cmd } var resticCmd = resticCmdFn var cmdTimeout = 10 * time.Second func (w *Wrapper) MountBackups(c *Config) error { c.CreateMountDirIfDoesntExist() w.Cleanup() // Open the folder first: MacFuse could take a second; results in occasional weird errors otherwise. err := openFolder(MountDir()) if err != nil { return err } w.mountCommand = resticCmd("--password-file", PasswordFile(), "-r", c.Repository, "mount", MountDir()) err = w.mountCommand.Start() // restic's mount is a blocking call if err != nil { return fmt.Errorf("restic mount cmd: %w", err) } return nil } // OpenConfigFile opens the restic config file using the open command. func OpenConfigFile() error { return open.Run(ConfigFile()) } // OpenLogs opens the restic logfile using the open command. func OpenLogs() error { return open.Run(LogFile()) } func openFolder(folder string) error { return open.Run(folder) } // UpdateLatestSnapshots updates LatestSnapshots or returns an error. If timed out, returns an error as well. // Returns a ResticCmdTimeoutError if no response from restic after cmdTimeout func (w *Wrapper) UpdateLatestSnapshots(c *Config) error { cmd := resticCmd("--json", "--password-file", PasswordFile(), "-r", c.Repository, "snapshots") done := make(chan bool, 1) var out []byte var err error go func() { out, err = cmd.CombinedOutput() done <- true }() select { case <-done: case <-time.After(cmdTimeout): cmd.Process.Kill() return fmt.Errorf("snapshot cmd: %w", ResticCmdTimeoutError) } if err != nil { return fmt.Errorf("snapshot cmd: %s: %w", string(out), err) } err = json.Unmarshal(out, &w.LatestSnapshots) if err != nil { return err } log.Info().Any("snapshots", w.LatestSnapshots).Msg("update") return nil } // Backup uses "restic backup" to create a new snapshot. This is a blocking call. // Returns a ResticCmdTimeoutError if no response from restic after cmdTimeout func (w *Wrapper) Backup(c *Config) error { cmd := resticCmd("--password-file", PasswordFile(), "-r", c.Repository, "--exclude-file", ExcludeFile(), "backup", c.Backup) stdout, _ := cmd.StdoutPipe() busy := make(chan bool, 1) cmd.Start() scanner := bufio.NewScanner(stdout) scanner.Split(bufio.ScanLines) go func() { for scanner.Scan() { m := scanner.Text() log.Info().Str("out", m).Msg("backup") if busy != nil { busy <- true } } }() select { case <-busy: busy = nil // It's alive! continue with cmd.Wait() case <-time.After(cmdTimeout): cmd.Process.Kill() return fmt.Errorf("restic snapshots cmd: %w", ResticCmdTimeoutError) } err := cmd.Wait() if err != nil { var exitErr *exec.ExitError if errors.As(err, &exitErr) && exitErr.ExitCode() == 3 { log.Warn().Err(err).Msg("At least one file could not be read, permission problem?") return nil } return fmt.Errorf("restic backup cmd: %w", err) } return nil }