package restic import ( "bufio" "encoding/json" "errors" "fmt" "github.com/rs/zerolog/log" "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 } 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", "restic").Run() } } func resticCmd(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 // This isn't ideal but I don't want to fiddle with build flags resticExec := filepath.Join(filepath.Dir(executable), "restic") if IsDev() { resticExec = "restic" // dev: assume in $PATH } cmd := exec.Command(resticExec, args...) log.Debug().Msg(cmd.String()) return cmd } /* UpdateLatestSnapshots updates LatestSnapshots or returns an error. Expected restic snapshot JSON format: [ { "time": "2023-03-01T16:15:34.111513+01:00", "tree": "d603aa4c6ce2bdef784dbdcd36461970d7f0cc8083d31f46d23cdb9bef172f0a", "paths": [ "/Users/user" ], "hostname": "M1-Air.local", "username": "user", "uid": 501, "gid": 20, "id": "31ae2a213c5750c4f86ebe8a8e989a5d4de2963c911e7513f47ca227723a0d95", "short_id": "31ae2a21" } ] */ func (w *Wrapper) UpdateLatestSnapshots(c *Config) error { cmd := resticCmd("--json", "--password-file", PasswordFile(), "-r", c.Repository, "snapshots") out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("restic snapshots 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 } 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(c.MountDir()) if err != nil { return err } w.mountCommand = resticCmd("--password-file", PasswordFile(), "-r", c.Repository, "mount", c.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 NON-BLOCKING "open" command. func OpenConfigFile() error { return exec.Command("open", ConfigFile()).Run() } // OpenLogs opens the restic logfile using the NON-BLOCKING "open" command. func OpenLogs() error { return exec.Command("open", LogFile()).Run() } func openFolder(folder string) error { cmd := exec.Command("open", folder) out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("open mount dir: %s: %w", string(out), err) } return nil } // Backup uses "restic backup" to create a new snapshot. This is a blocking call. func (w *Wrapper) Backup(c *Config) error { cmd := resticCmd("--password-file", PasswordFile(), "-r", c.Repository, "--exclude-file", ExcludeFile(), "backup", c.Backup, "--no-scan") 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(3 * time.Second): killErr := cmd.Process.Kill() return fmt.Errorf("backup output timeout, repository server down?: %w", killErr) } 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 }