repository_restic.go 6.12 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
	uri          string
	passwordFile string
	excludes     []string
	excludeFiles []string
26
	autoPrune    bool
ale's avatar
ale committed
27 28

	initialized bool
ale's avatar
ale committed
29 30
}

ale's avatar
ale committed
31 32
func (r *resticRepository) resticCmd() string {
	args := []string{r.bin, "-r", r.uri}
ale's avatar
ale committed
33
	if r.passwordFile != "" {
ale's avatar
ale committed
34
		args = append(args, "--password-file", r.passwordFile)
ale's avatar
ale committed
35 36
	}
	for _, x := range r.excludes {
ale's avatar
ale committed
37
		args = append(args, "--exclude", x)
ale's avatar
ale committed
38 39
	}
	for _, x := range r.excludeFiles {
ale's avatar
ale committed
40
		args = append(args, "--exclude-file", x)
ale's avatar
ale committed
41
	}
ale's avatar
ale committed
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 69
	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
70 71 72
}

// newResticRepository returns a restic repository.
ale's avatar
ale committed
73
func newResticRepository(params map[string]interface{}) (Repository, error) {
ale's avatar
ale committed
74 75 76 77 78 79 80 81 82 83 84
	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)
85 86 87
	bin := "restic"
	if s, ok := params["restic_binary"].(string); ok {
		bin = s
ale's avatar
ale committed
88 89 90 91
	}
	if err := checkResticVersion(bin); err != nil {
		return nil, err
	}
92 93 94 95
	autoPrune := true
	if b, ok := params["autoprune"].(bool); ok {
		autoPrune = b
	}
ale's avatar
ale committed
96 97 98 99 100 101

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

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

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

ale's avatar
ale committed
124 125 126 127 128
func (r *resticRepository) Init(ctx context.Context, rctx RuntimeContext) error {
	if r.initialized {
		return nil
	}

129 130
	// Restic init will fail the second time we run it, ignore
	// errors.
ale's avatar
ale committed
131
	err := rctx.Shell().Run(ctx, fmt.Sprintf(
132
		"%s init --quiet || true",
ale's avatar
ale committed
133
		r.resticCmd(),
ale's avatar
ale committed
134
	))
ale's avatar
ale committed
135 136 137 138
	if err == nil {
		r.initialized = true
	}
	return err
ale's avatar
ale committed
139 140
}

ale's avatar
ale committed
141
func (r *resticRepository) Prepare(ctx context.Context, rctx RuntimeContext, backup *Backup) error {
142 143 144
	if !r.autoPrune {
		return nil
	}
ale's avatar
ale committed
145
	return rctx.Shell().Run(ctx, fmt.Sprintf(
ale's avatar
ale committed
146 147
		"%s forget --host %s --keep-last 10 --prune",
		r.resticCmd(),
ale's avatar
ale committed
148 149 150 151
		backup.Host,
	))
}

ale's avatar
ale committed
152 153 154
func (r *resticRepository) BackupCmd(backup *Backup, ds *Dataset, sourcePaths []string) string {
	return fmt.Sprintf(
		"%s backup --cleanup-cache --exclude-caches --one-file-system --tag %s --tag backup_id=%s %s",
ale's avatar
ale committed
155
		r.resticCmd(),
ale's avatar
ale committed
156 157
		ds.Name,
		backup.ID,
ale's avatar
ale committed
158
		strings.Join(sourcePaths, " "),
ale's avatar
ale committed
159 160 161
	)
}

ale's avatar
ale committed
162 163
func (r *resticRepository) getSnapshotID(ctx context.Context, rctx RuntimeContext, backup *Backup, ds *Dataset) (string, error) {
	data, err := rctx.Shell().Output(ctx, fmt.Sprintf(
ale's avatar
ale committed
164 165
		"%s snapshots --json --tag backup_id=%s --tag %s",
		r.resticCmd(),
ale's avatar
ale committed
166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181
		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
}

ale's avatar
ale committed
182 183
func (r *resticRepository) RestoreCmd(ctx context.Context, rctx RuntimeContext, backup *Backup, ds *Dataset, paths []string, target string) (string, error) {
	snap, err := r.getSnapshotID(ctx, rctx, backup, ds)
ale's avatar
ale committed
184
	if err != nil {
ale's avatar
ale committed
185 186 187 188 189
		return "", err
	}

	cmd := []string{
		fmt.Sprintf("%s restore", r.resticCmd()),
ale's avatar
ale committed
190
	}
ale's avatar
ale committed
191 192 193 194 195 196 197 198 199 200 201 202 203 204

	for _, path := range paths {
		cmd = append(cmd, fmt.Sprintf("--include %s", path))
	}

	cmd = append(cmd, fmt.Sprintf("--target %s", target))
	cmd = append(cmd, snap)
	return strings.Join(cmd, " "), nil
}

func (r *resticRepository) BackupStreamCmd(backup *Backup, ds *Dataset) string {
	fakePath := fmt.Sprintf("/STDIN%s", strings.Replace(ds.Name, "/", "_", -1))
	return fmt.Sprintf(
		"%s backup --cleanup-cache --exclude-caches --tag %s --tag backup_id=%s --stdin --stdin-filename %s",
ale's avatar
ale committed
205
		r.resticCmd(),
ale's avatar
ale committed
206 207 208
		ds.Name,
		backup.ID,
		fakePath,
ale's avatar
ale committed
209 210 211
	)
}

ale's avatar
ale committed
212 213 214 215
func (r *resticRepository) RestoreStreamCmd(ctx context.Context, rctx RuntimeContext, backup *Backup, ds *Dataset, target string) (string, error) {
	snap, err := r.getSnapshotID(ctx, rctx, backup, ds)
	if err != nil {
		return "", err
ale's avatar
ale committed
216 217
	}

ale's avatar
ale committed
218 219 220 221 222 223 224 225 226 227 228
	fakePath := fmt.Sprintf("/STDIN%s", strings.Replace(ds.Name, "/", "_", -1))
	targetPath := filepath.Base(fakePath)

	// Restore the file to a temporary directory, then pipe it.
	return fmt.Sprintf(
		"(%s restore --target %s %s 1>&2 && cat %s)",
		r.resticCmd(),
		target,
		snap,
		filepath.Join(target, targetPath),
	), nil
ale's avatar
ale committed
229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246
}

// 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) {
247 248 249 250
	// An empty input is not necessarily an error.
	if len(output) > 0 {
		err = json.Unmarshal(output, &snapshots)
	}
ale's avatar
ale committed
251 252
	return
}