Progress output goes to stderr so --out - stays a clean pipe into restic, age or aws s3 cp. Archive names carry no colon, because these get copied onto Windows shares.
108 lines
3.1 KiB
Go
108 lines
3.1 KiB
Go
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"))
|
|
}
|