restictray/restic/wrapper.go

184 lines
5.0 KiB
Go

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
}