Merge pull request 'restic: restore a single kept file, not only a directory' (#56) from fix/restore-a-file into main
This commit was merged in pull request #56.
This commit is contained in:
@@ -440,7 +440,7 @@ func (b *Backups) Restore(ctx context.Context, module, snapshot, path string) (*
|
||||
if b.exists(ctx, target) {
|
||||
return nil, fmt.Errorf("%s already exists; nothing is restored over anything", target)
|
||||
}
|
||||
if _, err := b.restic(ctx, "restore", chosen.ID+":"+p, "--target", target); err != nil {
|
||||
if err := b.restoreOne(ctx, chosen.ID, p, target); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Restored = append(r.Restored, target)
|
||||
@@ -449,6 +449,51 @@ func (b *Backups) Restore(ctx context.Context, module, snapshot, path string) (*
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// restoreOne puts one kept path at target. A directory is restored as the snapshot's subfolder, so
|
||||
// its contents land directly in target; a single file cannot be — restic restores a subfolder, not a
|
||||
// file (a settings file refused with "not a directory" on the first restore of an app, 2026-10-05) —
|
||||
// so it is restored with its full path into a scratch directory beside target, moved into place, and
|
||||
// the scratch directory removed.
|
||||
func (b *Backups) restoreOne(ctx context.Context, id, path, target string) error {
|
||||
file, err := b.isFile(ctx, id, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !file {
|
||||
_, err := b.restic(ctx, "restore", id+":"+path, "--target", target)
|
||||
return err
|
||||
}
|
||||
scratch := target + ".partial"
|
||||
if _, err := b.restic(ctx, "restore", id, "--target", scratch, "--include", path); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := b.Run(ctx, "mv", scratch+path, target); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = b.Run(ctx, "rm", "-rf", scratch)
|
||||
return err
|
||||
}
|
||||
|
||||
// isFile is whether a kept path is a single file in the snapshot, as restic lists it.
|
||||
func (b *Backups) isFile(ctx context.Context, id, path string) (bool, error) {
|
||||
out, err := b.restic(ctx, "ls", "--json", id, path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
scanner := bufio.NewScanner(strings.NewReader(out))
|
||||
scanner.Buffer(make([]byte, 1024*1024), 16*1024*1024)
|
||||
for scanner.Scan() {
|
||||
var node struct {
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if json.Unmarshal(scanner.Bytes(), &node) == nil && node.Path == path {
|
||||
return node.Type == "file", nil
|
||||
}
|
||||
}
|
||||
return false, fmt.Errorf("restore point %s does not list %s", id, path)
|
||||
}
|
||||
|
||||
// Check is the weekly look at the repository's own integrity, with a sample of the data read back.
|
||||
func (b *Backups) Check(ctx context.Context) error {
|
||||
b.mu.Lock()
|
||||
|
||||
@@ -187,7 +187,9 @@ func TestWithTheRealResticAMistakeIsUndoneBesideTheLiveData(t *testing.T) {
|
||||
must(t, os.Mkdir(store, 0o700))
|
||||
must(t, os.Mkdir(dumps, 0o700))
|
||||
must(t, os.WriteFile(filepath.Join(store, "mailbox"), []byte("the only copy of a letter\n"), 0o600))
|
||||
where := placed(t, "# mail\npath "+store+"\n# pg\nrun echo 'every row' > "+dumps+"/all.dump\npath "+dumps+"\n")
|
||||
settings := filepath.Join(root, "settings.xml")
|
||||
must(t, os.WriteFile(settings, []byte("<Config>kept</Config>\n"), 0o600))
|
||||
where := placed(t, "# mail\npath "+store+"\npath "+settings+"\n# pg\nrun echo 'every row' > "+dumps+"/all.dump\npath "+dumps+"\n")
|
||||
// As whoever runs the test, against its own repository: no sudo.
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
out, err := exec.CommandContext(ctx, name, args...).Output()
|
||||
@@ -217,14 +219,36 @@ func TestWithTheRealResticAMistakeIsUndoneBesideTheLiveData(t *testing.T) {
|
||||
t.Fatalf("listed %+v", listed)
|
||||
}
|
||||
|
||||
// The mistake to a single file, too.
|
||||
must(t, os.WriteFile(settings, []byte("<Config>overwritten</Config>\n"), 0o600))
|
||||
|
||||
restored, err := b.Restore(ctx, "mail", "", "")
|
||||
must(t, err)
|
||||
if len(restored.Restored) != 1 || !strings.HasSuffix(restored.Restored[0], "store.restored-20261006-030000") {
|
||||
if len(restored.Restored) != 2 {
|
||||
t.Fatalf("restored %+v", restored)
|
||||
}
|
||||
if raw, _ := os.ReadFile(filepath.Join(restored.Restored[0], "mailbox")); string(raw) != "the only copy of a letter\n" {
|
||||
var dir, file string
|
||||
for _, r := range restored.Restored {
|
||||
switch {
|
||||
case strings.HasSuffix(r, "store.restored-20261006-030000"):
|
||||
dir = r
|
||||
case strings.HasSuffix(r, "settings.xml.restored-20261006-030000"):
|
||||
file = r
|
||||
}
|
||||
}
|
||||
if raw, _ := os.ReadFile(filepath.Join(dir, "mailbox")); string(raw) != "the only copy of a letter\n" {
|
||||
t.Fatalf("the restored letter: %q", raw)
|
||||
}
|
||||
// A single file comes back as a file beside the live one, and nothing of the scratch remains.
|
||||
if raw, _ := os.ReadFile(file); string(raw) != "<Config>kept</Config>\n" {
|
||||
t.Fatalf("the restored settings: %q", raw)
|
||||
}
|
||||
if raw, _ := os.ReadFile(settings); string(raw) != "<Config>overwritten</Config>\n" {
|
||||
t.Fatalf("the restore wrote over the live settings: %q", raw)
|
||||
}
|
||||
if _, err := os.Stat(file + ".partial"); err == nil {
|
||||
t.Fatal("the scratch directory was left behind")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(store, "mailbox")); err == nil {
|
||||
t.Fatal("the restore wrote into the live directory")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user