fix: read KEY_ENCRYPTION_KEY in archive-only verify; never leave a partial archive

This commit is contained in:
2026-09-07 14:45:36 +00:00
parent f1fffcf16c
commit bb45bf8093
5 changed files with 205 additions and 10 deletions
+56 -7
View File
@@ -42,11 +42,16 @@ func newBackupCmd() *cobra.Command {
}
defer client.Disconnect(context.Background())
w, closeOut, name, err := backupDestination(out, g.Database)
w, dest, name, err := backupDestination(out, g.Database)
if err != nil {
return err
}
defer closeOut()
committed := false
defer func() {
if !committed {
dest.Cleanup()
}
}()
m, err := backup.Dump(ctx, backup.DumpOptions{
Client: client,
@@ -60,6 +65,10 @@ func newBackupCmd() *cobra.Command {
if err != nil {
return err
}
if err := dest.Commit(); err != nil {
return err
}
committed = true
// Progress goes to stderr so --out - stays a clean pipe.
var docs int64
@@ -86,19 +95,59 @@ func newBackupCmd() *cobra.Command {
}
// backupDestination resolves --out to a writer, a closer and a name to print.
func backupDestination(out, database string) (io.Writer, func(), string, error) {
func backupDestination(out, database string) (io.Writer, destination, string, error) {
if out == "-" {
return os.Stdout, func() {}, "stdout", nil
return os.Stdout, stdoutDestination{}, "stdout", nil
}
name := archiveName(database, time.Now().UTC())
path := filepath.Join(out, name)
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
// Written under a temporary name and renamed on success, the same
// discipline the agent uses for authorized_keys: a failed backup must not
// leave a partial file named exactly like a good archive.
tmp := path + ".partial"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return nil, nil, "", fmt.Errorf("create %s: %w", path, err)
return nil, nil, "", fmt.Errorf("create %s: %w", tmp, err)
}
return f, func() { f.Close() }, path, nil
d := &fileDestination{f: f, tmp: tmp, final: path}
return f, d, path, nil
}
// fileDestination finishes a file-backed backup. Commit renames the temporary
// file into place; Cleanup removes it if Commit was never called.
type fileDestination struct {
f *os.File
tmp string
final string
}
func (d *fileDestination) Commit() error {
if err := d.f.Close(); err != nil {
return fmt.Errorf("close %s: %w", d.tmp, err)
}
if err := os.Rename(d.tmp, d.final); err != nil {
return fmt.Errorf("rename %s: %w", d.tmp, err)
}
return nil
}
func (d *fileDestination) Cleanup() {
d.f.Close()
os.Remove(d.tmp)
}
// destination is how the two --out modes finish. stdout commits by doing
// nothing; there is no partial file to clean up either.
type destination interface {
Commit() error
Cleanup()
}
type stdoutDestination struct{}
func (stdoutDestination) Commit() error { return nil }
func (stdoutDestination) Cleanup() {}
// archiveName is sortable and carries no colon, because an operator will copy
// these onto a Windows share sooner or later and a colon is not a legal
// filename character there.