You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
367 lines
9.1 KiB
367 lines
9.1 KiB
package main |
|
|
|
import ( |
|
"encoding/json" |
|
"errors" |
|
"fmt" |
|
"log" |
|
"os" |
|
"os/exec" |
|
"strings" |
|
"time" |
|
) |
|
|
|
var ErrRestic = errors.New("restic error") |
|
var ErrRepoNotFound = errors.New("repository not found or uninitialized") |
|
|
|
func lineIn(needle string, haystack []string) bool { |
|
for _, line := range haystack { |
|
if line == needle { |
|
return true |
|
} |
|
} |
|
|
|
return false |
|
} |
|
|
|
func maybeAddArgString(args []string, name, value string) []string { |
|
if value != "" { |
|
return append(args, name, value) |
|
} |
|
|
|
return args |
|
} |
|
|
|
func maybeAddArgInt(args []string, name string, value int) []string { |
|
if value > 0 { |
|
return append(args, name, fmt.Sprint(value)) |
|
} |
|
|
|
return args |
|
} |
|
|
|
func maybeAddArgBool(args []string, name string, value bool) []string { |
|
if value { |
|
return append(args, name) |
|
} |
|
|
|
return args |
|
} |
|
|
|
func maybeAddArgsList(args []string, name string, value []string) []string { |
|
for _, v := range value { |
|
args = append(args, name, v) |
|
} |
|
|
|
return args |
|
} |
|
|
|
type CommandOptions interface { |
|
ToArgs() []string |
|
} |
|
|
|
type GenericOpts []string |
|
|
|
func (o GenericOpts) ToArgs() []string { |
|
return o |
|
} |
|
|
|
type NoOpts struct{} |
|
|
|
func (NoOpts) ToArgs() []string { |
|
return []string{} |
|
} |
|
|
|
type BackupOpts struct { |
|
Exclude []string `hcl:"Exclude,optional"` |
|
Include []string `hcl:"Include,optional"` |
|
Tags []string `hcl:"Tags,optional"` |
|
Host string `hcl:"Host,optional"` |
|
} |
|
|
|
func (bo BackupOpts) ToArgs() (args []string) { |
|
args = maybeAddArgsList(args, "--exclude", bo.Exclude) |
|
args = maybeAddArgsList(args, "--include", bo.Include) |
|
args = maybeAddArgsList(args, "--tag", bo.Tags) |
|
args = maybeAddArgString(args, "--host", bo.Host) |
|
|
|
return |
|
} |
|
|
|
type RestoreOpts struct { |
|
Exclude []string `hcl:"Exclude,optional"` |
|
Include []string `hcl:"Include,optional"` |
|
Host []string `hcl:"Host,optional"` |
|
Tags []string `hcl:"Tags,optional"` |
|
Path string `hcl:"Path,optional"` |
|
Target string `hcl:"Target,optional"` |
|
Verify bool `hcl:"Verify,optional"` |
|
} |
|
|
|
func (ro RestoreOpts) ToArgs() (args []string) { |
|
args = maybeAddArgsList(args, "--exclude", ro.Exclude) |
|
args = maybeAddArgsList(args, "--include", ro.Include) |
|
args = maybeAddArgsList(args, "--host", ro.Host) |
|
args = maybeAddArgsList(args, "--tag", ro.Tags) |
|
args = maybeAddArgString(args, "--path", ro.Path) |
|
args = maybeAddArgString(args, "--target", ro.Target) |
|
args = maybeAddArgBool(args, "--verify", ro.Verify) |
|
|
|
return |
|
} |
|
|
|
type TagList []string |
|
|
|
func (t TagList) String() string { |
|
return strings.Join(t, ",") |
|
} |
|
|
|
type ForgetOpts struct { |
|
KeepLast int `hcl:"KeepLast,optional"` |
|
KeepHourly int `hcl:"KeepHourly,optional"` |
|
KeepDaily int `hcl:"KeepDaily,optional"` |
|
KeepWeekly int `hcl:"KeepWeekly,optional"` |
|
KeepMonthly int `hcl:"KeepMonthly,optional"` |
|
KeepYearly int `hcl:"KeepYearly,optional"` |
|
|
|
KeepWithin time.Duration `hcl:"KeepWithin,optional"` |
|
KeepWithinHourly time.Duration `hcl:"KeepWithinHourly,optional"` |
|
KeepWithinDaily time.Duration `hcl:"KeepWithinDaily,optional"` |
|
KeepWithinWeekly time.Duration `hcl:"KeepWithinWeekly,optional"` |
|
KeepWithinMonthly time.Duration `hcl:"KeepWithinMonthly,optional"` |
|
KeepWithinYearly time.Duration `hcl:"KeepWithinYearly,optional"` |
|
|
|
Tags []TagList `hcl:"Tags,optional"` |
|
KeepTags []TagList `hcl:"KeepTags,optional"` |
|
|
|
Prune bool `hcl:"Prune,optional"` |
|
} |
|
|
|
func (fo ForgetOpts) ToArgs() (args []string) { |
|
args = maybeAddArgInt(args, "--keep-last", fo.KeepLast) |
|
args = maybeAddArgInt(args, "--keep-hourly", fo.KeepHourly) |
|
args = maybeAddArgInt(args, "--keep-daily", fo.KeepDaily) |
|
args = maybeAddArgInt(args, "--keep-weekly", fo.KeepWeekly) |
|
args = maybeAddArgInt(args, "--keep-monthly", fo.KeepMonthly) |
|
args = maybeAddArgInt(args, "--keep-yearly", fo.KeepYearly) |
|
|
|
// Add keep-within-* |
|
|
|
if fo.KeepWithin > 0 { |
|
args = append(args, "--keep-within", fo.KeepWithin.String()) |
|
} |
|
|
|
if fo.KeepWithinHourly > 0 { |
|
args = append(args, "--keep-within-hourly", fo.KeepWithinHourly.String()) |
|
} |
|
|
|
if fo.KeepWithinDaily > 0 { |
|
args = append(args, "--keep-within-daily", fo.KeepWithinDaily.String()) |
|
} |
|
|
|
if fo.KeepWithinWeekly > 0 { |
|
args = append(args, "--keep-within-weekly", fo.KeepWithinWeekly.String()) |
|
} |
|
|
|
if fo.KeepWithinMonthly > 0 { |
|
args = append(args, "--keep-within-monthly", fo.KeepWithinMonthly.String()) |
|
} |
|
|
|
if fo.KeepWithinYearly > 0 { |
|
args = append(args, "--keep-within-yearly", fo.KeepWithinYearly.String()) |
|
} |
|
|
|
// Add tags |
|
for _, tagList := range fo.Tags { |
|
args = append(args, "--tag", tagList.String()) |
|
} |
|
|
|
for _, tagList := range fo.KeepTags { |
|
args = append(args, "--keep-tag", tagList.String()) |
|
} |
|
|
|
// Add prune options |
|
args = maybeAddArgBool(args, "--prune", fo.Prune) |
|
|
|
return args |
|
} |
|
|
|
type ResticGlobalOpts struct { |
|
CaCertFile string `hcl:"CaCertFile,optional"` |
|
CacheDir string `hcl:"CacheDir,optional"` |
|
PasswordFile string `hcl:"PasswordFile,optional"` |
|
TLSClientCertFile string `hcl:"TlsClientCertFile,optional"` |
|
LimitDownload int `hcl:"LimitDownload,optional"` |
|
LimitUpload int `hcl:"LimitUpload,optional"` |
|
VerboseLevel int `hcl:"VerboseLevel,optional"` |
|
CleanupCache bool `hcl:"CleanupCache,optional"` |
|
NoCache bool `hcl:"NoCache,optional"` |
|
NoLock bool `hcl:"NoLock,optional"` |
|
} |
|
|
|
func (glo ResticGlobalOpts) ToArgs() (args []string) { |
|
args = maybeAddArgString(args, "--cacert", glo.CaCertFile) |
|
args = maybeAddArgString(args, "--cache-dir", glo.CacheDir) |
|
args = maybeAddArgString(args, "--password-file", glo.PasswordFile) |
|
args = maybeAddArgString(args, "--tls-client-cert", glo.TLSClientCertFile) |
|
args = maybeAddArgInt(args, "--limit-download", glo.LimitDownload) |
|
args = maybeAddArgInt(args, "--limit-upload", glo.LimitUpload) |
|
args = maybeAddArgInt(args, "--verbose", glo.VerboseLevel) |
|
args = maybeAddArgBool(args, "--cleanup-cache", glo.CleanupCache) |
|
args = maybeAddArgBool(args, "--no-cache", glo.NoCache) |
|
args = maybeAddArgBool(args, "--no-lock", glo.NoLock) |
|
|
|
return args |
|
} |
|
|
|
type Restic struct { |
|
Logger *log.Logger |
|
Repo string |
|
Env map[string]string |
|
Passphrase string |
|
GlobalOpts *ResticGlobalOpts |
|
Cwd string |
|
} |
|
|
|
func (rcmd Restic) BuildEnv() []string { |
|
if rcmd.Env == nil { |
|
rcmd.Env = map[string]string{} |
|
} |
|
|
|
if rcmd.Passphrase != "" { |
|
rcmd.Env["RESTIC_PASSWORD"] = rcmd.Passphrase |
|
} |
|
|
|
envList := os.Environ() |
|
|
|
for name, value := range rcmd.Env { |
|
envList = append(envList, fmt.Sprintf("%s=%s", name, value)) |
|
} |
|
|
|
return envList |
|
} |
|
|
|
type ResticError struct { |
|
OriginalError error |
|
Command string |
|
Output []string |
|
} |
|
|
|
func NewResticError(command string, output []string, originalError error) *ResticError { |
|
return &ResticError{ |
|
OriginalError: originalError, |
|
Command: command, |
|
Output: output, |
|
} |
|
} |
|
|
|
func (e *ResticError) Error() string { |
|
return fmt.Sprintf( |
|
"error running restic %s: %s\nOutput:\n%s", |
|
e.Command, |
|
e.OriginalError, |
|
strings.Join(e.Output, "\n"), |
|
) |
|
} |
|
|
|
func (e *ResticError) Unwrap() error { |
|
return e.OriginalError |
|
} |
|
|
|
func (rcmd Restic) RunRestic(command string, options CommandOptions, commandArgs ...string) ([]string, error) { |
|
args := []string{} |
|
if rcmd.GlobalOpts != nil { |
|
args = rcmd.GlobalOpts.ToArgs() |
|
} |
|
|
|
args = append(args, "--repo", rcmd.Repo, command) |
|
args = append(args, options.ToArgs()...) |
|
args = append(args, commandArgs...) |
|
|
|
cmd := exec.Command("restic", args...) |
|
|
|
output := NewCapturedLogWriter(rcmd.Logger) |
|
cmd.Stdout = output |
|
cmd.Stderr = output |
|
cmd.Env = rcmd.BuildEnv() |
|
cmd.Dir = rcmd.Cwd |
|
|
|
if err := cmd.Run(); err != nil { |
|
responseErr := ErrRestic |
|
if lineIn("Is there a repository at the following location?", output.Lines) { |
|
responseErr = ErrRepoNotFound |
|
} |
|
|
|
return output.Lines, NewResticError(command, output.Lines, responseErr) |
|
} |
|
|
|
return output.Lines, nil |
|
} |
|
|
|
func (rcmd Restic) Backup(files []string, opts BackupOpts) error { |
|
_, err := rcmd.RunRestic("backup", opts, files...) |
|
|
|
return err |
|
} |
|
|
|
func (rcmd Restic) Restore(snapshot string, opts RestoreOpts) error { |
|
_, err := rcmd.RunRestic("restore", opts, snapshot) |
|
|
|
return err |
|
} |
|
|
|
func (rcmd Restic) Forget(forgetOpts ForgetOpts) error { |
|
_, err := rcmd.RunRestic("forget", forgetOpts) |
|
|
|
return err |
|
} |
|
|
|
func (rcmd Restic) Check() error { |
|
_, err := rcmd.RunRestic("check", NoOpts{}) |
|
|
|
return err |
|
} |
|
|
|
type Snapshot struct { |
|
UID int `json:"uid"` |
|
GID int `json:"gid"` |
|
Time time.Time `json:"time"` |
|
Tree string `json:"tree"` |
|
Hostname string `json:"hostname"` |
|
Username string `json:"username"` |
|
ID string `json:"id"` |
|
ShortID string `json:"short_id"` // nolint:tagliatelle |
|
Paths []string `json:"paths"` |
|
Tags []string `json:"tags,omitempty"` |
|
} |
|
|
|
func (rcmd Restic) ReadSnapshots() ([]Snapshot, error) { |
|
lines, err := rcmd.RunRestic("snapshots", GenericOpts{"--json"}) |
|
if err != nil { |
|
return nil, err |
|
} |
|
|
|
snapshots := new([]Snapshot) |
|
|
|
if err = json.Unmarshal([]byte(lines[0]), snapshots); err != nil { |
|
return nil, fmt.Errorf("failed parsing snapshot results: %w", err) |
|
} |
|
|
|
return *snapshots, nil |
|
} |
|
|
|
func (rcmd Restic) Snapshots() error { |
|
_, err := rcmd.RunRestic("snapshots", NoOpts{}) |
|
|
|
return err |
|
} |
|
|
|
func (rcmd Restic) EnsureInit() error { |
|
if err := rcmd.Snapshots(); errors.Is(err, ErrRepoNotFound) { |
|
_, err := rcmd.RunRestic("init", NoOpts{}) |
|
|
|
return err |
|
} |
|
|
|
return nil |
|
}
|
|
|