Files
vantage-ctl/internal/cmd/backup.go
T

157 lines
4.4 KiB
Go

package cmd
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"time"
"gitea.hostxtra.co.uk/vantage/vantage-shared/backup"
"github.com/spf13/cobra"
)
func newBackupCmd() *cobra.Command {
var (
out string
exclude []string
allowNoKey bool
)
c := &cobra.Command{
Use: "backup",
Short: "Write an archive of the database",
Long: "backup writes every collection in the database to a gzipped tar\n" +
"archive, along with a fingerprint of KEY_ENCRYPTION_KEY.\n\n" +
"The key itself is never written. The fingerprint is what lets a later\n" +
"restore refuse rather than produce a database whose secrets nobody\n" +
"can read.\n\n" +
"Pass --out - to stream to stdout, which is how this composes with\n" +
"restic, age, or aws s3 cp -.",
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
ctx := c.Context()
g, err := resolveGlobals(c)
if err != nil {
return err
}
client, err := connect(ctx, g)
if err != nil {
return err
}
defer client.Disconnect(context.Background())
w, dest, name, err := backupDestination(out, g.Database)
if err != nil {
return err
}
committed := false
defer func() {
if !committed {
dest.Cleanup()
}
}()
m, err := backup.Dump(ctx, backup.DumpOptions{
Client: client,
Database: g.Database,
Exclude: exclude,
KeyHex: g.KeyHex,
AllowNoKey: allowNoKey,
VantageVersion: c.Root().Version,
Out: w,
})
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
for _, coll := range m.Collections {
docs += coll.Documents
}
fmt.Fprintf(c.ErrOrStderr(), "wrote %s: %d collections, %d documents\n",
name, len(m.Collections), docs)
if m.KeyFingerprint == nil {
fmt.Fprintln(c.ErrOrStderr(),
"warning: no key recorded; nothing in this archive proves its "+
"ciphertext can ever be read")
}
return nil
},
}
c.Flags().StringVar(&out, "out", ".", "directory to write the archive into, or - for stdout")
c.Flags().StringSliceVar(&exclude, "exclude", nil,
"collections to leave out, comma separated (recorded in the manifest)")
c.Flags().BoolVar(&allowNoKey, "allow-no-key", false,
"back up without KEY_ENCRYPTION_KEY set; only for a deployment storing no encrypted data")
return c
}
// backupDestination resolves --out to a writer, a closer and a name to print.
func backupDestination(out, database string) (io.Writer, destination, string, error) {
if out == "-" {
return os.Stdout, stdoutDestination{}, "stdout", nil
}
name := archiveName(database, time.Now().UTC())
path := filepath.Join(out, name)
// 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", tmp, err)
}
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.
func archiveName(database string, at time.Time) string {
return fmt.Sprintf("vantage-backup-%s-%s.tar.gz", database, at.Format("20060102T150405Z"))
}