package cmd import ( "context" "fmt" "io" "os" "path/filepath" "time" "gitea.hostxtra.co.uk/mrhid6/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, closeOut, name, err := backupDestination(out, g.Database) if err != nil { return err } defer closeOut() 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 } // 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, func(), string, error) { if out == "-" { return os.Stdout, func() {}, "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) if err != nil { return nil, nil, "", fmt.Errorf("create %s: %w", path, err) } return f, func() { f.Close() }, path, nil } // 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")) }