repository_restic.go 5.75 KB
Newer Older
ale's avatar
ale committed
1 2 3 4 5 6 7 8 9 10
package tabacco

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"io/ioutil"
	"os"
ale's avatar
ale committed
11
	"os/exec"
ale's avatar
ale committed
12
	"path/filepath"
ale's avatar
ale committed
13
	"regexp"
ale's avatar
ale committed
14 15
	"strings"
	"time"
ale's avatar
ale committed
16 17

	"github.com/hashicorp/go-version"
ale's avatar
ale committed
18 19 20
)

type resticRepository struct {
ale's avatar
ale committed
21
	bin          string
ale's avatar
ale committed
22 23 24 25 26
	uri          string
	passwordFile string
	shell        *Shell
	excludes     []string
	excludeFiles []string
27
	autoPrune    bool
ale's avatar
ale committed
28 29
}

ale's avatar
ale committed
30 31
func (r *resticRepository) resticCmd() string {
	args := []string{r.bin, "-r", r.uri}
ale's avatar
ale committed
32
	if r.passwordFile != "" {
ale's avatar
ale committed
33
		args = append(args, "--password-file", r.passwordFile)
ale's avatar
ale committed
34 35
	}
	for _, x := range r.excludes {
ale's avatar
ale committed
36
		args = append(args, "--exclude", x)
ale's avatar
ale committed
37 38
	}
	for _, x := range r.excludeFiles {
ale's avatar
ale committed
39
		args = append(args, "--exclude-file", x)
ale's avatar
ale committed
40
	}
ale's avatar
ale committed
41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68
	return strings.Join(args, " ")
}

// We need to check that we're running at least restic 0.9, or the
// restore functionality won't work as expected.
var (
	resticVersionRx      = regexp.MustCompile(`^restic ([0-9.]+)`)
	resticMinGoodVersion = "0.9"
)

func checkResticVersion(bin string) error {
	output, err := exec.Command(bin, "version").Output() // #nosec
	if err != nil {
		return err
	}
	m := resticVersionRx.FindStringSubmatch(string(output))
	if len(m) < 2 {
		return errors.New("could not parse restic version")
	}
	v, err := version.NewVersion(m[1])
	if err != nil {
		return err
	}
	minV, _ := version.NewVersion(resticMinGoodVersion) // nolint
	if v.LessThan(minV) {
		return fmt.Errorf("restic should be at least version %s (is %s)", minV, v)
	}
	return nil
ale's avatar
ale committed
69 70 71 72 73 74 75 76 77 78 79 80 81 82 83
}

// newResticRepository returns a restic repository.
func newResticRepository(params map[string]interface{}, shell *Shell) (Repository, error) {
	uri, ok := params["uri"].(string)
	if !ok || uri == "" {
		return nil, errors.New("missing uri")
	}
	password, ok := params["password"].(string)
	if !ok || password == "" {
		return nil, errors.New("missing password")
	}

	ex, _ := params["exclude"].([]string)
	exf, _ := params["exclude_files"].([]string)
84 85 86
	bin := "restic"
	if s, ok := params["restic_binary"].(string); ok {
		bin = s
ale's avatar
ale committed
87 88 89 90
	}
	if err := checkResticVersion(bin); err != nil {
		return nil, err
	}
91 92 93 94
	autoPrune := true
	if b, ok := params["autoprune"].(bool); ok {
		autoPrune = b
	}
ale's avatar
ale committed
95 96 97 98 99 100

	tmpf, err := ioutil.TempFile("", "restic-pw-")
	if err != nil {
		return nil, err
	}
	if _, err := io.WriteString(tmpf, password); err != nil {
101
		os.Remove(tmpf.Name()) // nolint
ale's avatar
ale committed
102 103 104
		return nil, err
	}
	if err := tmpf.Close(); err != nil {
105
		os.Remove(tmpf.Name()) // nolint
ale's avatar
ale committed
106 107 108 109
		return nil, err
	}

	return &resticRepository{
ale's avatar
ale committed
110
		bin:          bin,
ale's avatar
ale committed
111 112 113 114 115
		uri:          uri,
		passwordFile: tmpf.Name(),
		excludes:     ex,
		excludeFiles: exf,
		shell:        shell,
116
		autoPrune:    autoPrune,
ale's avatar
ale committed
117 118 119 120 121 122 123 124
	}, nil
}

func (r *resticRepository) Close() error {
	return os.Remove(r.passwordFile)
}

func (r *resticRepository) Init(ctx context.Context) error {
125 126
	// Restic init will fail the second time we run it, ignore
	// errors.
ale's avatar
ale committed
127
	return r.shell.Run(ctx, fmt.Sprintf(
128
		"%s init --quiet || true",
ale's avatar
ale committed
129
		r.resticCmd(),
ale's avatar
ale committed
130 131 132 133
	))
}

func (r *resticRepository) Prepare(ctx context.Context, backup Backup) error {
134 135 136
	if !r.autoPrune {
		return nil
	}
ale's avatar
ale committed
137
	return r.shell.Run(ctx, fmt.Sprintf(
ale's avatar
ale committed
138 139
		"%s forget --host %s --keep-last 10 --prune",
		r.resticCmd(),
ale's avatar
ale committed
140 141 142 143 144 145
		backup.Host,
	))
}

func (r *resticRepository) Backup(ctx context.Context, backup Backup, ds Dataset, sourcePath string) error {
	cmd := fmt.Sprintf(
ale's avatar
ale committed
146 147
		"%s backup --cleanup-cache --exclude-caches --one-file-system --tag %s --tag backup_id=%s",
		r.resticCmd(),
ale's avatar
ale committed
148 149 150 151 152 153 154 155 156 157 158 159 160 161
		ds.Name,
		backup.ID,
	)
	for _, atom := range ds.Atoms {
		if atom.SourcePath == "" {
			return errors.New("atom without source path")
		}
		cmd += fmt.Sprintf(" %s", atom.SourcePath)
	}
	return r.shell.Run(ctx, cmd)
}

func (r *resticRepository) getSnapshotID(ctx context.Context, backup Backup, ds Dataset) (string, error) {
	data, err := r.shell.Output(ctx, fmt.Sprintf(
ale's avatar
ale committed
162 163
		"%s snapshots --json --tag backup_id=%s --tag %s",
		r.resticCmd(),
ale's avatar
ale committed
164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185
		backup.ID,
		ds.Name,
	))
	if err != nil {
		return "", err
	}
	snaps, err := parseResticSnapshots(data)
	if err != nil {
		return "", err
	}
	if len(snaps) < 1 {
		return "", fmt.Errorf("could not find snapshot for backup id %s", backup.ID)
	}
	return snaps[0].ShortID, nil
}

func (r *resticRepository) Restore(ctx context.Context, backup Backup, ds Dataset, target string) error {
	snap, err := r.getSnapshotID(ctx, backup, ds)
	if err != nil {
		return err
	}
	cmd := fmt.Sprintf(
ale's avatar
ale committed
186
		"%s restore",
ale's avatar
ale committed
187
		r.resticCmd(),
ale's avatar
ale committed
188 189
	)
	for _, atom := range ds.Atoms {
ale's avatar
ale committed
190 191 192
		if atom.SourcePath != "" {
			cmd += fmt.Sprintf(" --include %s", filepath.Join(atom.SourcePath))
		}
ale's avatar
ale committed
193 194
	}
	cmd += fmt.Sprintf(" --target %s", target)
ale's avatar
ale committed
195
	cmd += fmt.Sprintf(" %s", snap)
ale's avatar
ale committed
196 197 198 199 200 201 202
	return r.shell.Run(ctx, cmd)
}

func (r *resticRepository) BackupStream(ctx context.Context, backup Backup, ds Dataset, input io.Reader) error {
	// Try to do the obvious thing with naming.
	name := ds.Name
	if len(ds.Atoms) == 1 {
203
		name = fmt.Sprintf("%s.%s", ds.Name, ds.Atoms[0].Name)
ale's avatar
ale committed
204 205
	}
	return r.shell.Run(ctx, fmt.Sprintf(
godog's avatar
godog committed
206
		"%s backup --stdin --stdin-filename %s",
ale's avatar
ale committed
207
		r.resticCmd(),
ale's avatar
ale committed
208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232
		name,
	))
}

func (r *resticRepository) RestoreStream(_ context.Context, backup Backup, ds Dataset, target string, output io.Writer) error {
	// TODO.
	return nil
}

// Data about a snapshot, obtained from 'restic snapshots --json'.
type resticSnapshot struct {
	ID       string    `json:"id"`
	ShortID  string    `json:"short_id"`
	Time     time.Time `json:"time"`
	Parent   string    `json:"parent"`
	Tree     string    `json:"tree"`
	Hostname string    `json:"hostname"`
	Username string    `json:"username"`
	UID      int       `json:"uid"`
	GID      int       `json:"gid"`
	Paths    []string  `json:"paths"`
	Tags     []string  `json:"tags"`
}

func parseResticSnapshots(output []byte) (snapshots []resticSnapshot, err error) {
233 234 235 236
	// An empty input is not necessarily an error.
	if len(output) > 0 {
		err = json.Unmarshal(output, &snapshots)
	}
ale's avatar
ale committed
237 238
	return
}