2023-03-04 20:54:10 +01:00
|
|
|
package restic
|
|
|
|
|
|
|
|
import (
|
2023-03-05 17:49:36 +01:00
|
|
|
"bufio"
|
2023-03-04 20:54:10 +01:00
|
|
|
"encoding/json"
|
2023-03-05 17:49:36 +01:00
|
|
|
"errors"
|
2023-03-04 20:54:10 +01:00
|
|
|
"fmt"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
|
|
"os/exec"
|
2023-03-05 17:02:41 +01:00
|
|
|
"path/filepath"
|
2023-03-04 20:54:10 +01:00
|
|
|
"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
|
|
|
|
}
|
|
|
|
|
2023-03-08 21:12:15 +01:00
|
|
|
var ResticCmdTimeoutError = errors.New("backup output timeout, repository server down?")
|
2023-03-08 20:58:09 +01:00
|
|
|
|
2023-03-04 20:54:10 +01:00
|
|
|
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]
|
|
|
|
}
|
|
|
|
|
2023-03-05 17:02:41 +01:00
|
|
|
// 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()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-03-07 13:50:04 +01:00
|
|
|
func resticCmdFn(args ...string) *exec.Cmd {
|
2023-03-05 17:02:41 +01:00
|
|
|
// 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...)
|
2023-03-04 20:54:10 +01:00
|
|
|
log.Debug().Msg(cmd.String())
|
|
|
|
return cmd
|
|
|
|
}
|
|
|
|
|
2023-03-07 13:50:04 +01:00
|
|
|
var resticCmd = resticCmdFn
|
|
|
|
var cmdTimeout = 10 * time.Second
|
|
|
|
|
2023-03-04 20:54:10 +01:00
|
|
|
func (w *Wrapper) MountBackups(c *Config) error {
|
|
|
|
c.CreateMountDirIfDoesntExist()
|
2023-03-05 17:02:41 +01:00
|
|
|
w.Cleanup()
|
2023-03-04 20:54:10 +01:00
|
|
|
|
|
|
|
// Open the folder first: MacFuse could take a second; results in occasional weird errors otherwise.
|
2023-03-05 17:02:41 +01:00
|
|
|
err := openFolder(c.MountDir())
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2023-03-04 20:54:10 +01:00
|
|
|
|
2023-03-05 17:02:41 +01:00
|
|
|
w.mountCommand = resticCmd("--password-file", PasswordFile(), "-r", c.Repository, "mount", c.MountDir())
|
|
|
|
err = w.mountCommand.Start() // restic's mount is a blocking call
|
2023-03-04 20:54:10 +01:00
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("restic mount cmd: %w", err)
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2023-03-05 17:02:41 +01:00
|
|
|
// 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()
|
|
|
|
}
|
|
|
|
|
2023-03-04 20:54:10 +01:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2023-03-08 20:58:09 +01:00
|
|
|
// UpdateLatestSnapshots updates LatestSnapshots or returns an error. If timed out, returns an error as well.
|
2023-03-14 20:00:04 +01:00
|
|
|
// Returns a ResticCmdTimeoutError if no response from restic after cmdTimeout
|
2023-03-08 20:58:09 +01:00
|
|
|
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):
|
2023-03-08 21:12:15 +01:00
|
|
|
cmd.Process.Kill()
|
2023-03-14 20:00:04 +01:00
|
|
|
return fmt.Errorf("snapshot cmd: %w", ResticCmdTimeoutError)
|
2023-03-08 20:58:09 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
if err != nil {
|
2023-03-14 20:00:04 +01:00
|
|
|
return fmt.Errorf("snapshot cmd: %s: %w", string(out), err)
|
2023-03-08 20:58:09 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
err = json.Unmarshal(out, &w.LatestSnapshots)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
log.Info().Any("snapshots", w.LatestSnapshots).Msg("update")
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2023-03-05 17:49:36 +01:00
|
|
|
// Backup uses "restic backup" to create a new snapshot. This is a blocking call.
|
2023-03-14 20:00:04 +01:00
|
|
|
// Returns a ResticCmdTimeoutError if no response from restic after cmdTimeout
|
2023-03-04 20:54:10 +01:00
|
|
|
func (w *Wrapper) Backup(c *Config) error {
|
2023-03-05 17:49:36 +01:00
|
|
|
cmd := resticCmd("--password-file", PasswordFile(), "-r", c.Repository, "--exclude-file", ExcludeFile(), "backup", c.Backup, "--no-scan")
|
|
|
|
stdout, _ := cmd.StdoutPipe()
|
2023-03-06 21:09:27 +01:00
|
|
|
|
|
|
|
busy := make(chan bool, 1)
|
2023-03-05 17:49:36 +01:00
|
|
|
cmd.Start()
|
|
|
|
|
|
|
|
scanner := bufio.NewScanner(stdout)
|
|
|
|
scanner.Split(bufio.ScanLines)
|
2023-03-06 21:09:27 +01:00
|
|
|
|
|
|
|
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()
|
2023-03-07 13:50:04 +01:00
|
|
|
case <-time.After(cmdTimeout):
|
2023-03-08 21:12:15 +01:00
|
|
|
cmd.Process.Kill()
|
|
|
|
return fmt.Errorf("restic snapshots cmd: %w", ResticCmdTimeoutError)
|
2023-03-05 17:49:36 +01:00
|
|
|
}
|
|
|
|
|
2023-03-06 21:09:27 +01:00
|
|
|
err := cmd.Wait()
|
2023-03-04 20:54:10 +01:00
|
|
|
if err != nil {
|
2023-03-05 17:49:36 +01:00
|
|
|
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)
|
2023-03-04 20:54:10 +01:00
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|