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")) }